From 82a4154caeda3d2be16d49d761dec6b82cc81680 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 27 Jul 2026 22:49:36 +0200 Subject: [PATCH 01/42] docs: plan the stateful-models program from the issue #18 survey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compares morph against Axelor, Jmix, Causeway and Orleans by use case against examples/bank rather than by feature-list parity. The finding: every bank model is stateless (its only member is the inherited DataMapper), so the per-instance strand protects nothing and a BridgeHandler registering one instance per handler gives the desktop GUI five AccountModel instances for one logical thing. Adds §F to docs/todo.md with three accepted items and a refusal table, and one spec per item under docs/planned/. Co-Authored-By: Claude Opus 5 (1M context) --- docs/planned/instance_subscriptions.md | 314 ++++++++++++++++++++ docs/planned/shared_model_instances.md | 387 +++++++++++++++++++++++++ docs/planned/stateful_bank_example.md | 326 +++++++++++++++++++++ docs/todo.md | 138 ++++++++- 4 files changed, 1154 insertions(+), 11 deletions(-) create mode 100644 docs/planned/instance_subscriptions.md create mode 100644 docs/planned/shared_model_instances.md create mode 100644 docs/planned/stateful_bank_example.md diff --git a/docs/planned/instance_subscriptions.md b/docs/planned/instance_subscriptions.md new file mode 100644 index 00000000..a5496904 --- /dev/null +++ b/docs/planned/instance_subscriptions.md @@ -0,0 +1,314 @@ +# Instance subscriptions — planned + +**Status:** planned, not implemented. This document is a design proposal, not a +description of current behaviour. The authoritative present-tense specs are in +[`docs/spec/`](../spec). + +This item **removes** the reactive-draft mechanism described in +[bridge.md](../spec/core/bridge.md) and +[ARCHITECTURE.md](../ARCHITECTURE.md) ("Subscriptions and fielded actions") and +replaces it. See [What this removes](#what-this-removes) for the blast radius. + +## Contents + +- [The gap this closes](#the-gap-this-closes) +- [The new meaning of `subscribe`](#the-new-meaning-of-subscribe) +- [Why keyed on the result type](#why-keyed-on-the-result-type) +- [Delivery semantics](#delivery-semantics) +- [Wire protocol changes](#wire-protocol-changes) +- [What this removes](#what-this-removes) +- [Rebuilding reactive forms on the new primitive](#rebuilding-reactive-forms-on-the-new-primitive) +- [Reworking `morph::flows`](#reworking-morphflows) +- [API reference](#api-reference) +- [Design decisions](#design-decisions) +- [Failure modes](#failure-modes) +- [Limitations](#limitations) +- [Cross-references](#cross-references) + +## The gap this closes + +Once instances are shared ([shared_model_instances.md](shared_model_instances.md)), +two handlers — possibly in two different client processes — operate on the same +stateful model. Nothing tells either of them that the other changed it. + +morph has no server-initiated message at all. The `Envelope` protocol is +strictly request/reply, and `views.md` records "no live/push list updates" as a +non-goal. The only tool that *sounds* like a subscription, +`BridgeHandler::subscribe()`, is a client-side draft mechanism: it streams +field values into a local draft, fires when `ActionValidator::ready` passes, +and hands the caller that action's result. It never hears about anything anyone +else did. + +So a shared instance is currently a shared secret: `a1` and `a2` both hold +account 42, `a2` deposits, and `a1` shows a stale balance until something makes +it ask again. + +## The new meaning of `subscribe` + +A subscription is keyed on the **result / state type**, and fires whenever a +value of that type is produced on the instance the handler is attached to — by +*any* handler attached to it, on any connection. + +```cpp +BridgeHandler a1{bridge, gui}; +BridgeHandler a2{bridge, gui}; +a1.attach(42); +a2.attach(42); + +a1.subscribe([](AccountInfo a) { + showBalance(a.balanceMinor); // fires for a2's work too +}); + +a2.execute(Deposit{.amountMinor = 5000}); // produces an AccountInfo +// → a1's callback runs, on a1's own gui executor +``` + +The subscriber names *what it wants to see*, not *what someone else must do to +produce it*. `a1` does not need to know that `Deposit` exists, that `a2` exists, +or that a deposit is what changed the balance — only that an `AccountInfo` is +the shape of the state it renders. + +## Why keyed on the result type + +Keying on the action type was the other candidate, and it is what the current +draft mechanism does. It is the wrong choice here for three reasons: + +- **A subscriber is a renderer, not a caller.** A GUI panel showing a balance + cares about `AccountInfo`. Requiring it to enumerate every action that might + produce one (`Deposit`, `Withdraw`, `GetAccount`, `CloseAccount`, and every + action added later) makes every new action a breaking change for every + subscriber. +- **It composes with stateful models.** A keyed model's actions are mostly + keyless mutations of one state; the state type is the stable, meaningful + identity in that design, and the action set is the volatile part. +- **It is the shape the existing schema layer already assumes.** + [views.md](../spec/forms/views.md) derives its columns from the query action's + *row type*, and `AccountInfo` is described there as "the Account model's + primary result type". The result type is already the thing morph's own + generation layer treats as the model's public shape. + +## Delivery semantics + +- **Scope is the instance.** A subscription is bound to the instance the handler + is attached to at the time it fires, not to the model *type*. Re-pointing the + handler ([shared_model_instances.md](shared_model_instances.md#re-pointing-not-re-keying)) + moves its subscriptions to the new instance. +- **A handler with no primary receives only its own results.** Nothing else is + attached, so there is nothing else to hear. +- **Callbacks run on the handler's executor**, exactly as `.then` does today. + Two handlers in one process with different executors each get their callback + where they asked for it. +- **The originating handler is notified too.** `a2` executing `Deposit` gets its + ordinary `Completion` result *and*, if subscribed, its subscription callback. + Suppressing the echo would force every subscriber to special-case "was this + mine", which is exactly the bookkeeping the feature exists to remove. +- **Ordering is per instance.** Because every action on an instance runs on that + instance's strand, notifications are naturally ordered and that order is + guaranteed. No ordering is guaranteed *between* instances. +- **Delivery is best-effort and unbuffered.** A notification produced while a + client is disconnected is lost. There is no replay, no cursor, no + checkpointing. On reconnect a client re-reads state the ordinary way; the + subscription resumes from then on. This is deliberate — see + [Limitations](#limitations). +- **Failed actions notify nobody.** A notification is produced from a successful + result only. +- **One callback per `(handler, result type)`.** Subscribing again replaces the + previous callback, matching the current cardinality rule. + +## Wire protocol changes + +This introduces **the first server-initiated message in morph**. Until now every +frame a client receives is a reply to something it sent, and both transports, +the reconnect logic, and the fuzz harness assume it. That assumption ends here, +and every one of those places needs revisiting. + +- **A new `subscribe` / `unsubscribe` request pair**, carrying a model id and a + result type id. The server records `(modelId, resultTypeId) → set`. +- **A new `notify` server-initiated message**, carrying the model id, the result + type id, and the result payload. It has no `callId`, because it answers + nothing. +- **Client dispatch must gain an unsolicited-message path.** `QtWebSocketBackend` + and `morph::net`'s `SocketBackend` both currently correlate every inbound + frame to a pending call; an uncorrelated frame is presently an error and must + become a routed notification. + +Subscriptions are connection-scoped and die with the connection, so +`closeConnection` drops them alongside its instance references. They are gated +by `authorize` for the model type — a principal that may not execute against a +model may not subscribe to its results either, or the subscription becomes a +read channel that bypasses authorization. + +## What this removes + +The reactive-draft mechanism is deleted, not deprecated. Removed API: + +| Removed | What it did | +|---|---| +| `subscribe(cb)` *(old meaning)* | Registered a result callback for action `A`'s draft | +| `set<&A::field>(value)` | Streamed one field into the client-side draft | +| `unsubscribe()` *(old meaning)* | Dropped the draft's callback | +| `reset()` | Destroyed the draft | +| in-flight coalescing | Collapsed patches landing during a flight into one re-fire | +| draft persistence across `switchBackend` | Kept drafts alive over a backend swap | + +`subscribe` keeps its name with new semantics. The break is loud rather than +silent: the callback's parameter changes from *the action's result* to *the +subscribed type itself*, so existing call sites fail to compile rather than +quietly changing behaviour. + +`ActionValidator::ready` **survives**. Its original purpose was gating a +draft fire, but A1 made it the server-side validation hook enforced in the +dispatcher runner and in `Bridge::executeVia`'s `localOp` +([registry.md](../spec/core/registry.md)). It keeps that role and loses the +draft one. Its documentation must be rewritten accordingly — the phrase +"decides whether a partially-built action draft is ready to execute" becomes +wrong. + +**Blast radius** (from the current tree): + +- `include/morph/core/bridge.hpp` — the draft storage and `set<>`/`reset<>` path. +- `include/morph/forms/flows.hpp` — `FlowSession` is built directly on + `subscribe` / `unsubscribe`. See below. +- `tests/test_subscription.cpp` (~69 uses), `tests/test_coverage_gaps.cpp` + (~16), `tests/test_computed_fields.cpp`, `tests/test_coverage_extra.cpp`, + `tests/test_flows_apps.cpp`, `tests/test_example.cpp`, + `examples/bank/tests/test_payee.cpp`. +- `src/qt/forms/` — `DynamicForm.qml`'s reactive path and + `tst_DynamicFormReactive.qml`. +- Docs: `ARCHITECTURE.md`'s "Subscriptions and fielded actions", + `spec/core/bridge.md`, `spec/forms/workflows_navigation.md`, + `docs/superpowers/2026-07-06-reactive-forms-bridge.md`, and + `examples/bank/README.md`. + +morph is `0.1.0` and [VERSIONING.md](../spec/VERSIONING.md) reserves exactly +this latitude before 1.0. The removal should still land as one reviewable +change with its replacement, not as a bare deletion. + +## Rebuilding reactive forms on the new primitive + +The draft mechanism solved a real problem: a form where each widget edits one +field and the UI responds live. Dropping it is only defensible because stateful +models solve the same problem better — by putting the draft **in the model** +instead of in the client. + +Before, the draft lived on the client and fired a whole action when a validator +said it was complete: + +```cpp +handler.subscribe([](Density d) { show(d); }); +handler.set<&ComputeDensity::mass>(m); +handler.set<&ComputeDensity::volume>(v); // validator passes → fires +``` + +After, the draft is model state, each edit is an ordinary action, and the UI +subscribes to the state type: + +```cpp +handler.subscribe([](DraftState s) { show(s); }); +handler.execute(SetMass{.value = m}); +handler.execute(SetVolume{.value = v}); // model recomputes, emits DraftState +``` + +This is more round trips, and that is the honest cost. What it buys: the draft +survives a client restart, two clients editing the same draft see each other, +readiness is decided by the model that owns the rules rather than by a +client-side predicate, and there is one execution path instead of two. It also +removes the in-flight coalescing machinery, whose subtleties exist only because +the draft was remote from its validator. + +Forms whose draft genuinely is client-local — a throwaway dialog — should build +the action normally and call `execute` once. That was always the simpler path +and is now the only one. + +## Reworking `morph::flows` + +`FlowSession` drives each wizard step through +`subscribe` / `unsubscribe` on the step's action type, and is a shipped +feature (E-G8) with its own spec and QML renderer. + +Re-expressed on the new primitive, a wizard becomes a **stateful model keyed by +flow instance**: steps are actions against it, the accumulated draft is its +state, and `WizardView.qml` subscribes to that state type instead of to each +step action. This is a better fit than the current design — it gives wizards +resumability and cross-client visibility for free, and removes +`FlowSession`'s per-step subscribe/unsubscribe churn. + +It is also a substantial rewrite of a shipped subsystem, and it should be +scoped and specified separately rather than folded into this item. Until it is, +`morph::flows` blocks this removal. + +## API reference + +| Symbol | Signature | Meaning | +|---|---|---| +| `handler.subscribe(cb)` | `void(std::function)` | Fire `cb` whenever an `R` is produced on the attached instance. Replaces any prior callback for `R`. | +| `handler.unsubscribe()` | `void` | Drop the callback for `R`. | + +## Design decisions + +- **Keyed on the result type, not the action type.** A subscriber describes what + it renders, not what someone else must call. Detailed above. +- **The originator is notified too.** No "was this mine" bookkeeping in every + subscriber. +- **Best-effort, unbuffered, no replay.** Durable streams with cursors and + checkpoints are a distributed-runtime feature; morph is a UI bridge. A client + that missed a notification re-reads state, which it already knows how to do. +- **Connection-scoped subscriptions.** They die with the transport, so there is + no cleanup story beyond the one `closeConnection` already implements. +- **Gated by `authorize` on the model type.** A subscription is a read channel; + leaving it ungated would let a principal observe results it may not request. +- **The draft mechanism is removed rather than kept alongside.** Two mechanisms + both named "subscription", with different keying and different scopes, is the + kind of ambiguity the specs exist to prevent. + +## Failure modes + +- **A slow or blocked subscriber.** Notifications are posted to the subscriber's + executor; a subscriber that blocks its executor delays its own callbacks and + nothing else. It must not be able to stall the producing instance's strand — + the notification is handed off, never awaited. +- **Notification storms.** A model producing a result per keystroke notifies + every attached client per keystroke. There is no coalescing (the draft + mechanism's coalescing is being removed, not carried over). A model that emits + at high frequency must throttle itself. +- **Uncorrelated frames in older clients.** A client built before this change + treats an unsolicited `notify` as a protocol error. Servers must only send + notifications to connections that subscribed, which by construction are new + clients — but the negotiated protocol version from A6 should gate it + explicitly rather than relying on that. +- **Subscription outliving its instance.** When an instance is destroyed + (attach count reaches zero) its subscriptions are dropped. A handler still + holding a callback for it simply stops hearing anything; re-attaching + re-establishes the subscription. +- **Result type collision across models.** Two model types producing the same + result type are distinguished by model id, not by result type alone; the + server's map is keyed on `(modelId, resultTypeId)`. + +## Limitations + +- **No replay, no durability, no ordering across instances.** Stated above. +- **No filtering.** A subscriber receives every `R` produced on the instance; it + cannot ask for a subset. +- **No subscription to a model type in general** — only to a specific attached + instance. "Tell me about every account" is not expressible. +- **No back-pressure.** A producer never learns that a subscriber is slow. +- **`morph::flows` must be reworked first.** This removal cannot land while + `FlowSession` depends on the mechanism it deletes. + +## Cross-references + +- [shared_model_instances.md](shared_model_instances.md) — instances, + attachment, and re-pointing, which define a subscription's scope. +- [stateful_bank_example.md](stateful_bank_example.md) — the state types a + subscriber would name. +- [bridge.md](../spec/core/bridge.md) — the draft mechanism being removed. +- [wire.md](../spec/core/wire.md) — the envelope, and the request/reply + assumption this breaks. +- [backend.md](../spec/core/backend.md) — connection scopes and the transports + that need an unsolicited-message path. +- [registry.md](../spec/core/registry.md) — `ActionValidator`, which survives + with a narrowed role. +- [workflows_navigation.md](../spec/forms/workflows_navigation.md) — + `FlowSession`, which must be reworked first. +- [VERSIONING.md](../spec/VERSIONING.md) — the pre-1.0 latitude this removal + relies on. diff --git a/docs/planned/shared_model_instances.md b/docs/planned/shared_model_instances.md new file mode 100644 index 00000000..844dd648 --- /dev/null +++ b/docs/planned/shared_model_instances.md @@ -0,0 +1,387 @@ +# Keyed, shareable model instances — planned + +**Status:** planned, not implemented. This document is a design proposal, not a +description of current behaviour. The authoritative present-tense specs are in +[`docs/spec/`](../spec). + +## Contents + +- [The gap this closes](#the-gap-this-closes) +- [What morph already has](#what-morph-already-has) +- [Declaring a primary key](#declaring-a-primary-key) +- [Where the key comes from](#where-the-key-comes-from) +- [`AllowShared` — opting a handler into sharing](#allowshared--opting-a-handler-into-sharing) +- [Re-pointing, not re-keying](#re-pointing-not-re-keying) +- [The instance directory](#the-instance-directory) +- [Enumerating live instances](#enumerating-live-instances) +- [Wire protocol changes](#wire-protocol-changes) +- [Ownership and authorization](#ownership-and-authorization) +- [Lifetime and the A7 connection-scope change](#lifetime-and-the-a7-connection-scope-change) +- [API reference](#api-reference) +- [Design decisions](#design-decisions) +- [Failure modes](#failure-modes) +- [Limitations](#limitations) +- [Non-goals](#non-goals) +- [Cross-references](#cross-references) + +## The gap this closes + +`Bridge::registerHandler()` unconditionally constructs a fresh +`HandlerBinding` and calls `registerModelWithContext(...)`. Every +`BridgeHandler` is therefore a new model instance, always. There is no way to +name an instance, no way to ask for one that already exists, and no way to find +out which ones are alive. + +The cost is visible in `examples/bank`: five controllers each construct a +`BridgeHandler`, so the desktop GUI holds five +`AccountModel` instances — and, because each model lazily opens its own +`Lightweight::DataMapper`, five SQLite connections — for what is logically one +thing. + +Once models hold state ([stateful_bank_example.md](stateful_bank_example.md)) +the problem stops being wasteful and starts being wrong: five instances of +account 42 are five divergent copies of that account's balance. + +## What morph already has + +Most of the mechanism is present and only needs connecting. + +- **A stable per-instance identity already exists.** `HandlerBinding::contextKey` + is documented as "stable identity of this model instance (e.g. an account + id)" and already travels in the `register` wire envelope + ([wire.md](../spec/core/wire.md)). It is used **only** for journal entity keys + and server-side log attachment; `wire.md`'s design-decision table states + explicitly that `contextKey` plays no part in instance routing. The vocabulary + is there; the routing is not. +- **Structural trait detection is an established pattern.** + [views.md](../spec/forms/views.md) detects `kind`, `query`, `title`, `rowKey` + and friends "via a `requires`-expression, not inheritance or a marker base". + A model's key type is declared the same way. +- **Per-instance authorization exists.** `IAuthorizer::authorizeInstance` is + consulted on every `execute` and every `deregister`, carrying the instance id + and its recorded owner ([session.md](../spec/session/session.md)). +- **One strand per instance** already gives a shared instance the serialisation + it needs; sharing an instance changes nothing about how its actions run. + +## Declaring a primary key + +A model declares its key type as a nested alias. Declaring it is what makes the +model keyed; a model without it keeps today's behaviour exactly. + +```cpp +class AccountModel { +public: + using PrimaryKey = std::int64_t; // detected structurally + // ... +}; +``` + +`PrimaryKey` must be a type morph can carry on the wire and use as a map key: +an integral type or `std::string`. The key type is what +[`instances()`](#enumerating-live-instances) is typed on, so it is visible in +user code as `AccountModel::PrimaryKey`. + +## Where the key comes from + +A keyed action declares which of its fields carries the key. Different actions +spell it differently, which is why the declaration is per action rather than per +model: + +```cpp +BRIDGE_KEY_FROM(GetAccount, &GetAccount::id) +BRIDGE_KEY_FROM(Deposit, &Deposit::accountId) +BRIDGE_KEY_FROM(CloseAccount, &CloseAccount::id) +``` + +An action that *creates* the entity has no key to carry — it produces one. Such +an action sources the key from its **result**, exactly as a database insert +returns its generated primary key: + +```cpp +BRIDGE_KEY_FROM_RESULT(OpenAccount, &dto::AccountInfo::id) +``` + +Actions with neither declaration are **keyless**, and most actions are: they run +on whichever instance the handler is currently attached to and say nothing about +identity. This is the common case, not the exception — a stateful model's whole +point is that its actions operate on state the instance already holds. + +A handler may also attach explicitly, without going through an action: + +```cpp +handler.attach(42); +``` + +## `AllowShared` — opting a handler into sharing + +Sharing eligibility is a **static** property of the handler, expressed as a +template parameter. The primary key is discovered **dynamically**, from +whichever action first supplies one. + +```cpp +BridgeHandler a1{bridge, gui}; // no primary yet +BridgeHandler a2{bridge, gui}; // no primary yet +BridgeHandler a3{bridge, gui}; // opts out + +a1.execute(GetAccount{.id = 42}); // sets primary 42 → modelId 1 +a2.execute(GetAccount{.id = 42}); // primary 42 already live → attaches to modelId 1 +a3.execute(GetAccount{.id = 42}); // sets primary 42 → modelId 2, deliberately separate +``` + +`BridgeHandler` — the spelling every existing call site uses — is +`BridgeHandler`, and behaves byte-for-byte as it does today: +its own instance, never entered into the directory, never handed to anyone +else. This is what keeps the feature backward compatible. + +Because a shared handler starts with no primary, **it registers nothing at +construction**. The `register` envelope is deferred until the handler knows +which instance it wants. A handler that only ever runs keyless actions gets a +private instance created on first execute; see +[Failure modes](#failure-modes). + +## Re-pointing, not re-keying + +A primary is **not** write-once. A handler already attached to account 42 may +execute a keyed action naming 43: + +```cpp +a1.execute(GetAccount{.id = 42}); // attached to the instance for 42 +a1.execute(GetAccount{.id = 43}); // now attached to the instance for 43 +``` + +This **re-points the handler** to the instance holding key 43, creating that +instance if it does not exist. The instance for 42 is untouched — it keeps its +identity and its state, and survives if any other handler is still attached. + +Instances never mutate their own identity. That is a deliberate simplification +and it is what makes the rule total: because a key always maps to exactly one +instance and an instance never changes key, there is no collision case to +resolve, no merge semantics to define, and no window in which the directory +disagrees with itself. Re-pointing gives the account-switching behaviour a GUI +actually wants, without any of that. + +Re-pointing a handler does **not** cancel its in-flight calls. A call dispatched +against instance 1 completes against instance 1 and delivers its result +normally; only subsequent calls go to the new instance. + +## The instance directory + +The directory lives **server-side**, in `RemoteServer`, so instances are +reusable across clients. Two connections that attach to key 42 reach the same +instance and see each other's state — that is the point of (a), and it is what +distinguishes this from a client-side handle cache. + +The directory maps `(modelTypeId, primaryKey) → ModelId`, held under the same +`_regMtx` that guards `_models`/`_owners`, so directory membership can never +desync from instance existence — the same invariant the connection-scope map +already maintains ([backend.md](../spec/core/backend.md), "Connection scopes"). + +Only instances created by an `AllowShared` handler are entered. A plain +handler's instance is invisible to the directory and unreachable by key. + +In local mode (`LocalBackend`) the directory lives in the backend rather than +the server, with identical semantics. The call site is unchanged between the +two, as morph requires everywhere. + +## Enumerating live instances + +```cpp +handler.instances() // Completion> + .then([](std::vector keys) { /* {42, 43, 71} */ }); +``` + +**This must be asynchronous.** The directory is server state, so in remote mode +answering it is a round trip. morph's core rule is that a call site is identical +local and remote, so the local implementation returns an already-resolved +`Completion` rather than the API returning a bare `std::vector` that only works +in-process. + +The result is a snapshot, not a live view, and it is stale the moment it +arrives — another client may attach or release between the reply being built +and the callback running. Callers must treat a returned key as "was live +recently", never as a guarantee that a subsequent `attach` finds the same +instance. + +Only *shared* instances are enumerable. Plain handlers' instances are absent by +construction. + +## Wire protocol changes + +Three additive changes. All are compatible with the additive-only evolution +policy in [wire.md](../spec/core/wire.md), and the lenient decoding that A6 +established means an older peer ignores what it does not understand. + +- **`register` grows `primary` and `shared`.** `primary` is the key as a string + (integral keys are decimal-encoded); `shared` is a bool. A `register` with + `shared: true` is a *register-or-attach*: the server returns the existing + `ModelId` for that `(typeId, primary)` if one is live, otherwise creates one + and enters it in the directory. `shared: false` or absent is today's + behaviour exactly. +- **A new `attach` request.** Re-points an existing binding at a different + primary without tearing down and recreating it, returning the target + `ModelId`. Semantically a `deregister` + `register` pair, made atomic so a + re-pointing handler cannot lose its slot to `LimitPolicy::maxLiveModels` in + between. +- **A new `instances` request.** Takes a model type id, replies with the live + primary keys for it. Subject to `authorize` like any other request; see below. + +`contextKey` keeps its current meaning and is **not** overloaded as the primary. +The two coincide in practice — a keyed model will normally set `contextKey` to +its primary so journal entries carry the entity key — but conflating them would +silently change behaviour for anyone already setting `contextKey` for journal +purposes, which the framework's opt-in discipline forbids. + +## Ownership and authorization + +`RemoteServer` records an `ownerPrincipal` for each instance at register time +and consults `authorizeInstance` on every execute, with the documented typical +policy being `ownerPrincipal.empty() || ownerPrincipal == ctx.principal` +([session.md](../spec/session/session.md)). + +Under that policy, a second client attaching to an instance the first client +created would be **rejected**. Cross-client sharing and per-instance ownership +are therefore mutually exclusive, and the design must say so rather than let an +authorizer silently defeat the feature: + +- **A shared instance is ownerless.** Its `ownerPrincipal` is empty, so the + standard policy admits every principal, and gating access to it is the job of + `authorize` (per model type and action) or of the model itself. +- **`authorizeRegister` still gates creation.** An authorizer that refuses + `register` for a model type refuses it whether or not the request is shared. +- **`instances` is gated by `authorize`** for the model type with an empty + action id, so an authorizer can refuse enumeration without refusing use. It + leaks the set of live keys to anyone permitted to call it, which is a + meaningful disclosure for key spaces that are themselves sensitive — the + security spec must call this out. + +An application that needs per-instance ownership on a shared model must enforce +it inside the model, from `Context::principal`, which is the same advice +`security.md` already gives for security-critical checks. + +## Lifetime and the A7 connection-scope change + +This item **changes shipped behaviour**, which nothing in the current +`todo.md` program did. It is unavoidable. + +`closeConnection(cid)` today "erases every model still recorded in `cid`'s +scope". With cross-client sharing, that would destroy an instance another live +client is still attached to. The scope entry must therefore become a +**reference**, not ownership: + +- Each attach — from any connection — increments an instance's attach count. +- `deregister`, handler destruction, and `closeConnection` each decrement. +- The instance is destroyed when the count reaches zero, at which point it + leaves the directory. +- `closeConnection` remains idempotent and still bypasses `IAuthorizer`; it + decrements once per scope entry regardless of how many handlers a single + connection had attached. + +Unshared instances have exactly one attacher by construction, so their lifetime +is unchanged: count reaches zero on the same event that erases them today. + +An optional idle grace period (keep a zero-count shared instance alive for *n* +seconds in case another client re-attaches) is **not** part of this work. +Default behaviour is immediate destruction at zero. + +`LimitPolicy::maxLiveModels` counts instances, not attachments, so sharing +strictly reduces pressure on it. + +## API reference + +| Symbol | Signature | Meaning | +|---|---|---| +| `Model::PrimaryKey` | nested type alias | Declares the model keyed. Integral or `std::string`. Detected structurally. | +| `morph::bridge::AllowShared` | tag type | Second template argument of `BridgeHandler`. Opts the handler into the directory. | +| `morph::bridge::NoSharing` | tag type | The default. Today's isolated-instance behaviour. | +| `BRIDGE_KEY_FROM(A, &A::field)` | macro | Declares that action `A` carries its model's key in `field`. | +| `BRIDGE_KEY_FROM_RESULT(A, &R::field)` | macro | Declares that `A`'s *result* establishes the key. | +| `handler.attach(key)` | `void` | Attaches (or re-points) without executing an action. | +| `handler.primary()` | `std::optional` | The handler's current primary; empty if unattached. | +| `handler.instances()` | `Completion>` | Snapshot of live shared keys for this model type. | + +## Design decisions + +- **Sharing is static, identity is dynamic.** Whether a handler *may* share is a + compile-time property, so it is visible at the declaration and cannot vary per + call. Which instance it shares is a runtime property, because that is what the + user is choosing at runtime. +- **The directory is server-side.** A client-side cache would solve the bank's + five-connections problem but not the one that matters — two clients diverging + on the same entity. Server-side is the whole reason this needs wire changes. +- **Instances never change key.** Re-pointing the handler achieves the same user + goal with none of the collision, merge, or directory-consistency cases. +- **`contextKey` is not reused as the primary.** It has a shipped meaning; + overloading it would change behaviour for existing users silently. +- **Shared instances are ownerless.** The alternative — teaching + `authorizeInstance` about a set of owners — makes a simple, shipped, verified + hook substantially more complex to serve a case the model layer can handle. +- **`instances()` is async even locally.** Local/remote call-site symmetry is + the framework's most load-bearing promise; a synchronous convenience overload + would be the first place it broke. + +## Failure modes + +- **A shared handler that runs a keyless action while unattached** gets a + private instance with no primary, which can never enter the directory. It is + then an `AllowShared` handler that shares nothing. The recommended discipline + is *keyed action, or `attach`, first*; the implementation should make this + observable — `primary()` returning empty after a successful execute is the + signal — rather than silently degrading. +- **Attaching to a key whose entity does not exist.** The directory happily + creates an instance; hydration fails inside the model and the action completes + through `onError`. The instance must not be left in the directory in a + half-hydrated state, so a failed *first* action on a freshly created shared + instance releases it. +- **`instances()` raced against `attach`.** Documented as inherent: the snapshot + is stale on arrival. An `attach` to a key from a stale list is not an error — + it simply creates the instance again. +- **Re-pointing with calls in flight.** In-flight calls complete against the old + instance. A caller that assumes `.then` runs against the newly attached + instance is wrong; the spec must state the ordering explicitly. +- **`closeConnection` under-counting.** If a connection attaches the same + instance from two handlers, the scope must record two references, or closing + it leaks one. This is the main correctness risk in the A7 change and needs a + dedicated test. + +## Limitations + +- **No activation on demand from persistence.** An instance exists because a + handler asked for it, not because the key exists in a database. There is no + "the instance always exists, virtually" guarantee. +- **No idle deactivation.** Zero attachments destroys immediately; there is no + keep-alive, no LRU, no eviction policy. +- **No key derived from the session principal.** A per-user model must be + attached explicitly after login. +- **One key per instance.** No secondary keys, no alternate indexes, no + querying the directory by anything but model type. +- **Enumeration is per model type and unfiltered.** No paging, no predicate; a + model type with very many live instances returns all of them. +- **`instances()` discloses live keys** to any principal `authorize` admits. + +## Non-goals + +- **Not virtual actors.** No perpetual existence, no placement, no clustering, + no activation-on-message-to-a-cold-key. The directory tracks what exists; it + does not conjure it. +- **No cross-instance transactions.** Two shared instances are two strands; an + operation spanning both is a domain concern, as it is today. +- **No state persistence provider.** What an instance holds and how it is loaded + is entirely the model's business — see + [stateful_bank_example.md](stateful_bank_example.md). + +## Cross-references + +- [stateful_bank_example.md](stateful_bank_example.md) — the demonstrator; a key + identifies nothing until models hold state. +- [instance_subscriptions.md](instance_subscriptions.md) — how attached handlers + learn that a shared instance changed. +- [bridge.md](../spec/core/bridge.md) — `HandlerBinding`, `contextKey`, + `registerHandler`, and `switchBackend`'s re-registration path. +- [backend.md](../spec/core/backend.md) — `RemoteServer`, connection scopes + (A7), and `LimitPolicy`. +- [wire.md](../spec/core/wire.md) — the envelope, additive evolution, and the + `contextKey`-vs-`modelId` decision this proposal preserves. +- [session.md](../spec/session/session.md) — `authorizeInstance`, + `authorizeRegister`, and the recorded owner principal. +- [security.md](../spec/security.md) — the per-instance ownership hook and the + trust boundary the ownerless-shared-instance decision sits inside. diff --git a/docs/planned/stateful_bank_example.md b/docs/planned/stateful_bank_example.md new file mode 100644 index 00000000..a63ab2d7 --- /dev/null +++ b/docs/planned/stateful_bank_example.md @@ -0,0 +1,326 @@ +# Reshaping `examples/bank` onto stateful models — planned + +**Status:** planned, not implemented. This document is a design proposal, not a +description of current behaviour. The authoritative present-tense specs are in +[`docs/spec/`](../spec). + +## Contents + +- [The gap this closes](#the-gap-this-closes) +- [Why the current bank cannot demonstrate morph](#why-the-current-bank-cannot-demonstrate-morph) +- [The reshaped model set](#the-reshaped-model-set) +- [`AccountModel` — the worked example](#accountmodel--the-worked-example) +- [`CustomerModel` — the per-user repository](#customermodel--the-per-user-repository) +- [`LedgerModel` — where cross-instance atomicity lives](#ledgermodel--where-cross-instance-atomicity-lives) +- [Hydration, write-through, and deactivation](#hydration-write-through-and-deactivation) +- [What the GUI stops doing](#what-the-gui-stops-doing) +- [The WASM build](#the-wasm-build) +- [Migration order](#migration-order) +- [Design decisions](#design-decisions) +- [Failure modes](#failure-modes) +- [Limitations](#limitations) +- [Cross-references](#cross-references) + +## The gap this closes + +morph's central claim is in the README's first paragraphs: *you write the model +as plain, single-threaded C++, and the framework owns concurrency — one strand +per model instance serialises that model's calls, so model authors never touch a +mutex.* + +`examples/bank` is the library's largest worked example and the one a reader +reaches for to see that claim in action. It does not demonstrate it. Every bank +model is **stateless**: the only member any of them declares is the +`std::optional` inherited from +`bank::db::WithMapper` — a database connection, not domain state. + +That means the per-model strand protects nothing. There is no state to +serialise access to, no state to keep in memory, and no state that a second +handler could usefully share. Every action is a full round trip to SQLite, so +the example demonstrates the *bridge* while leaving morph's model layer looking +like a thin RPC shim over a database. + +This document proposes reshaping the bank so its models hold the state they are +named after. It is a prerequisite for +[shared_model_instances.md](shared_model_instances.md) and +[instance_subscriptions.md](instance_subscriptions.md) having any demonstrable +effect: a primary key identifies nothing when instances carry nothing, and +sharing an instance preserves nothing when there is nothing to preserve. + +## Why the current bank cannot demonstrate morph + +Concretely, today: + +```cpp +class AccountModel : private db::WithMapper { +public: + dto::AccountInfo execute(const dto::OpenAccount&); + dto::AccountList execute(const dto::ListAccounts&); + dto::AccountInfo execute(const dto::GetAccount&); + dto::CommandResult execute(const dto::CloseAccount&); +}; +``` + +One `AccountModel` answers for **every** account of **every** user. Its +identity is nothing; its state is nothing. Three consequences follow, all +visible in the shipped GUI: + +- **Five instances, five connections.** `AccountController`, + `TransactionController`, `LoanController`, `CardController` and + `PayeeController` each construct a `BridgeHandler`. Since + a handler registers one instance ([bridge.md](../spec/core/bridge.md)), the + desktop GUI holds five `AccountModel` instances and therefore opens five + SQLite connections for what is logically one thing. +- **Reads cost a query.** `GetAccount` re-selects a row the process may have + read a millisecond earlier, because nothing retains it. +- **The strand is decorative.** Its documented purpose is to let a model own + mutable state without locking. No bank model has any. + +## The reshaped model set + +The reshape splits today's per-*domain* models into models keyed by the entity +they are actually about. Each keyed model type declares its key as a nested +alias, detected structurally (see +[shared_model_instances.md](shared_model_instances.md)). + +| Model | Key | In-memory state | Actions | +|---|---|---|---| +| `AccountModel` | `AccountId` (account row id) | one `AccountRecord`: balance, status, overdraft, currency, kind | `GetAccount`, `Deposit`, `Withdraw`, `CloseAccount` | +| `CustomerModel` | `UserId` (owner) | the customer row + their account id list | `ListAccounts`, `OpenAccount` | +| `LedgerModel` | *(unkeyed)* | none — owns the atomic write | `Transfer`, `History` | +| `AuthModel` | *(unkeyed)* | none | `Login`, `Logout` | + +`LoanModel`, `CardModel`, `PayeeModel`, `PaymentModel`, `StatementModel`, +`BudgetModel` and `NotificationModel` keep their current shape in the first +pass; see [Migration order](#migration-order). + +The split is the point: `ListAccounts` was never an account-scoped operation — +it is scoped by *user*, which is why the current single model has to take an +`owner` field on half its actions. Once `AccountModel` is keyed by account, +those fields disappear, because the instance already knows which account it is. + +## `AccountModel` — the worked example + +```cpp +namespace bank { + +/// One customer account, held in memory for the lifetime of the instance. +class AccountModel : private db::WithMapper { +public: + /// The primary key type. Detected structurally by morph; declaring it is + /// what makes this model keyed. + using PrimaryKey = std::int64_t; + + dto::AccountInfo execute(const dto::GetAccount&); + dto::AccountInfo execute(const dto::Deposit&); + dto::AccountInfo execute(const dto::Withdraw&); + dto::CommandResult execute(const dto::CloseAccount&); + +private: + void hydrate(); ///< load `_row` from SQLite on first use + void writeThrough(); ///< persist `_row` after a mutation + + db::AccountRecord _row{}; ///< the account — in memory, not re-queried + bool _loaded = false; +}; + +} // namespace bank +``` + +The action DTOs lose the id fields that only existed to say *which* account: + +```cpp +// before // after +struct GetAccount { std::int64_t id; }; struct GetAccount {}; +struct Deposit { std::int64_t accountId; struct Deposit { std::int64_t amountMinor; }; + std::int64_t amountMinor; }; +struct CloseAccount { std::int64_t id; }; struct CloseAccount {}; +``` + +and the key is instead declared once per action, naming the field that carries +it — or, for actions that no longer carry one, nothing at all, in which case the +action runs on whichever instance the handler is already attached to: + +```cpp +BRIDGE_REGISTER_MODEL(AccountModel, "AccountModel") +BRIDGE_REGISTER_ACTION(AccountModel, GetAccount, "GetAccount", Loggable::No) +BRIDGE_REGISTER_ACTION(AccountModel, Deposit, "Deposit") +BRIDGE_REGISTER_ACTION(AccountModel, Withdraw, "Withdraw") +BRIDGE_REGISTER_ACTION(AccountModel, CloseAccount, "CloseAccount") +``` + +The GUI attaches by key and then stops mentioning ids: + +```cpp +BridgeHandler account{bridge, gui}; + +account.attach(42); // or: any keyed action re-points it +account.execute(Deposit{.amountMinor = 5000}) + .then([](AccountInfo a) { /* a.balanceMinor is authoritative, from memory */ }); +``` + +`Deposit` now reads and writes `_row.balanceMinor` directly. The overdraft check +that today re-selects the row is a field comparison. The strand that morph has +always provided is now load-bearing: it is what makes the unlocked +read-modify-write of `_row` correct. + +## `CustomerModel` — the per-user repository + +```cpp +class CustomerModel : private db::WithMapper { +public: + using PrimaryKey = std::int64_t; // user id + + dto::AccountList execute(const dto::ListAccounts&); // no `owner` field + dto::AccountInfo execute(const dto::OpenAccount&); // no `owner` field +}; +``` + +`OpenAccount` is the *creating* action: it inserts a row and its result carries +the new id. That is the result-sourced key case in +[shared_model_instances.md](shared_model_instances.md) — a handler can adopt the +new account's key straight from the result, exactly as a database insert +returns its generated primary key: + +```cpp +BRIDGE_KEY_FROM_RESULT(OpenAccount, &dto::AccountInfo::id) +``` + +`CustomerModel`'s key comes from the authenticated principal rather than an +action field. The first pass resolves it explicitly at login +(`customer.attach(session.userId)`); making the session principal a first-class +key source is deliberately **not** part of this work — see +[Limitations](#limitations). + +## `LedgerModel` — where cross-instance atomicity lives + +`Transfer` moves money between two accounts, so with per-account instances it +touches two models. morph has no cross-instance transaction and this proposal +does not add one — consistent with the framework's standing position that +conflict resolution and multi-entity consistency are domain concerns +([ARCHITECTURE.md](../ARCHITECTURE.md), "Conflict Resolution — a domain concern, +not a framework concern"). + +`Transfer` therefore stays on an unkeyed `LedgerModel` which owns the +`SqlTransaction` that debits one row and credits the other atomically, exactly +as today. The consequence is explicit and must be documented in the example's +README: **after a transfer, any live `AccountModel` instance for either account +holds a stale balance.** The first pass resolves this the blunt way — the ledger +marks both instances dirty and they re-hydrate on their next action. +[instance_subscriptions.md](instance_subscriptions.md) is what would let the GUI +learn about it without asking. + +This is the sharpest honest edge of the whole design, and the example should +show it rather than arrange the domain to avoid it. + +## Hydration, write-through, and deactivation + +- **Hydration is lazy and on-strand.** The first action on an instance loads its + row, on the strand thread, mirroring how `WithMapper` already defers opening + the `DataMapper`. A key naming a row that does not exist fails that action + with `NotFound`; the instance is not retained. +- **Writes are write-through, not write-behind.** A mutating action updates + `_row` and persists it before returning. This keeps SQLite authoritative, so a + crash loses nothing and a deactivated instance can always be reconstructed. + Write-behind would be faster and is explicitly out of scope: it would make the + in-memory copy authoritative and demand a durability story the example has no + business inventing. +- **Deactivation just drops memory.** Releasing an instance discards `_row`; the + database is unchanged. Re-attaching re-hydrates. Nothing in the example + depends on an instance surviving. + +## What the GUI stops doing + +The five `BridgeHandler` become `AllowShared` handlers attached to +the account the user is looking at, so the desktop GUI holds one instance per +*viewed account* rather than one per *controller*. `TransactionController` and +`LoanController` stop constructing their own `AccountModel` purely to re-list +accounts; they attach to `CustomerModel` instead. + +`AccountController::refresh()`'s `ListAccounts` round trip after every mutation +(`AccountController.cpp:63`, `TransactionController.cpp:97`) is not removed by +this work — that is the invalidation problem, out of scope here — but it becomes +cheaper, because the balance the GUI re-reads comes from memory. + +## The WASM build + +`examples/bank/gui_wasm` carries shadow model headers and an in-memory store +(`gui_wasm/include/bank/wasm/store.hpp`) that reimplement every model against a +non-SQLite backing. Those shadows must be reshaped in the same commit, or the +WASM demo silently diverges from the desktop one. + +The reshape is *easier* there: a stateful model over an in-memory store is +closer to what the WASM shadows already are. This is a good forcing function — +if the reshaped model is awkward to express against a plain in-memory store, the +model is carrying persistence concerns it should not. + +## Migration order + +1. `AccountModel` + `CustomerModel` + `LedgerModel`, desktop only. This is the + whole idea; everything after it is repetition. +2. The `gui_wasm` shadows for the same three. +3. `examples/bank/tests` — the per-model tests become per-instance tests, which + is where the state actually gets asserted. +4. `LoanModel` and `CardModel` (keyed by loan / card id), same pattern. +5. `PayeeModel`, `PaymentModel`, `StatementModel`, `BudgetModel`, + `NotificationModel` — keyed by owner, i.e. `CustomerModel`-shaped. +6. `examples/bank/README.md`, whose "Architecture: two type layers" section + describes the stateless shape and must be rewritten. + +Steps 1–3 are the deliverable; 4–6 can follow independently. + +## Design decisions + +- **Split by entity, not by domain.** The current models are named for domains + (`AccountModel` handles all accounts). Keying them by the entity they are + named after is what gives the key something to identify. The `owner` fields + scattered across today's DTOs are the symptom of the missing split. +- **SQLite stays authoritative.** The model holds a cache with identity, not a + system of record. This keeps the example honest about what morph does and does + not own, and keeps `journal`'s replay semantics + ([journal.md](../spec/journal/journal.md)) unchanged. +- **`Transfer` stays on an unkeyed model.** Making it a cross-instance operation + would require inventing cross-strand atomicity, which morph does not have and + which this example must not imply it has. +- **No session-sourced keys in this pass.** `CustomerModel` attaching from an + explicit user id keeps the key mechanism to one concept. + +## Failure modes + +- **A key naming a non-existent row.** Hydration fails, the action completes + through `onError` with `NotFound`, and no instance is retained. It must not + leave a half-hydrated instance in the directory. +- **A stale `AccountModel` after `Transfer`.** Documented above and visible in + the example by design. The dirty-and-re-hydrate mitigation must be an + explicit, commented mechanism, not an accident of timing. +- **Two instances for the same account** — impossible for `AllowShared` handlers + (the directory guarantees one per key) but expected for plain handlers, which + keep today's isolated-instance behaviour. Two isolated instances of the same + account both write through to the same row, so the last writer wins. The + example should use `AllowShared` throughout and say why. + +## Limitations + +- **The session principal is not a key source.** `CustomerModel` must be + attached explicitly after login. Deriving a key from the authenticated + principal is a plausible follow-up, not part of this work. +- **No cross-instance transaction.** Stated above; a domain concern by design. +- **Write-through only.** No batching, no write-behind, no dirty-flush policy. +- **The first pass leaves six models unreshaped**, so the example is + temporarily mixed-paradigm. The README must say which models are which rather + than let a reader infer that the un-migrated ones are the intended pattern. + +## Cross-references + +- [shared_model_instances.md](shared_model_instances.md) — the keyed-instance + mechanism this example is the demonstrator for. +- [instance_subscriptions.md](instance_subscriptions.md) — how a GUI learns that + a shared instance changed. +- [bridge.md](../spec/core/bridge.md) — `BridgeHandler`, `HandlerBinding`, and + the one-instance-per-handler rule this reshape works within. +- [registry.md](../spec/core/registry.md) — `BRIDGE_REGISTER_*`, model factories, + and the default-constructibility requirement for remotely instantiated models. +- [journal.md](../spec/journal/journal.md) — `contextKey` as an entity key, which + a keyed model supplies naturally. +- [ARCHITECTURE.md](../ARCHITECTURE.md) — "Conflict Resolution — a domain + concern, not a framework concern". diff --git a/docs/todo.md b/docs/todo.md index 4b5e15b8..f4fa70cd 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,10 +1,120 @@ -# Production-hardening program — status +# Program tracker + +Two programs live here: + +- **[§F — Stateful models](#f-stateful-models-open)** is **open**. It came out of + [issue #18](https://github.com/LASTRADA-Software/morph/issues/18) ("compare + against Axelor / Jmix / Causeway / Orleans and find out what we are missing"). + Its designs are in [`docs/planned/`](planned). +- **§A–§E — Production hardening & GUI generation** is **shipped**, kept for + the rationale (priority, dependency order) that motivated the work. + Present-tense designs for those are in [`docs/spec/`](spec). + +--- + +# §F. Stateful models (open) + +## What the issue #18 survey actually found + +The four projects named in the issue are not comparable to morph as a set: +Axelor, Jmix and Causeway are full-stack Java business-application platforms; +Orleans is a .NET distributed actor runtime. Reading their feature lists side by +side produces a backlog of ~25 items, most of which morph should never build — +it does not own a database, a process engine, or an IDE. + +So the survey was done the other way round: against **use cases in +`examples/bank`**, morph's largest worked example, asking where a developer hits +a wall rather than which boxes are unticked. That produced one finding that +subsumes most of the others. + +**morph's models are anonymous and stateless, so the model layer is doing +nothing.** Every bank model's only member is the `std::optional` it +inherits from `WithMapper` — a connection, not domain state. The per-instance +strand that morph advertises as its core service therefore protects nothing; +every action is a full round trip to SQLite; and because a `BridgeHandler` +registers one instance, the desktop GUI holds five `AccountModel` instances (and +five SQLite connections) for what is logically one thing. + +Orleans is the comparator that names this directly — a grain is *identity + +behaviour + state*, and morph has only behaviour. But the fix is not to become +an actor runtime. It is to make morph's existing model layer do the job it +already claims: hold state, be identified, be reachable. + +## Accepted items + +### F1 — Reshape `examples/bank` onto stateful models · P0 · planned + +Split the per-domain models into models keyed by the entity they are named +after, holding that entity in memory, hydrated on activation and written +through on mutation. This is first deliberately: it is what introduces the main +idea of the library, and F2/F3 are unverifiable without it — a primary key +identifies nothing when instances carry nothing. + +See [`planned/stateful_bank_example.md`](planned/stateful_bank_example.md). + +### F2 — Keyed, shareable model instances · P0 · planned + +A model declares a `PrimaryKey`; actions declare which field carries it (or that +their *result* establishes it); `BridgeHandler` opts a handler +into a **server-side** instance directory, so instances are reusable across +clients. `instances()` enumerates the live keys. A keyed action re-points a +handler rather than re-keying an instance, so key collisions do not arise. + +Carries a change to shipped behaviour: A7's `closeConnection` must decrement a +reference count rather than erase, or one client's disconnect destroys an +instance another client is using. + +See [`planned/shared_model_instances.md`](planned/shared_model_instances.md). + +### F3 — Instance subscriptions · P1 · planned + +`subscribe(cb)` keyed on the **result/state** type, firing whenever an `R` is +produced on the attached instance by any handler on any connection. Introduces +morph's first server-initiated wire message. + +**Removes** the reactive-draft mechanism (`set<&A::field>`, `reset`, the old +action-keyed `subscribe`, in-flight coalescing), whose job stateful models do +better by holding the draft server-side. Blocked on reworking `morph::flows`, +which is built on the mechanism being deleted. + +See [`planned/instance_subscriptions.md`](planned/instance_subscriptions.md). + +## Considered and refused + +Recorded so the survey does not get re-run and so the boundary is explicit. + +| Not building | Why | +|---|---| +| **Entity metamodel / naked objects** (Causeway) | morph's unit is the *action*, deliberately. Rows plus row-bound actions already are an object UI; what is missing is metadata on the row, not a second parallel metamodel. | +| **Server-authoritative per-instance action availability** ("see it, use it, do it") | Real gap — `CloseAccount` is implemented, tested and exposed in no GUI because the client cannot ask "may I?". Judged not worth the surface area now. | +| **Query invalidation / live lists** | Superseded in part by F3, which gives shared instances a change channel without a query-invalidation vocabulary. | +| **Paging / sorting contract on view schemas** | `views.md` already records "no server-side query language" as a non-goal; paging stays a field on the query action. | +| **Result-type presentation metadata** (money, enum labels, badge severity) | Every GUI controller hand-writes a `toMap()` projection. Genuine duplication, but it is a forms-layer concern, not a model-layer one. | +| **Field- and row-level permissions** (Jmix, Causeway SecMan) | They own the ORM and the whole app; morph owns a seam. Per-principal schema redaction would break the one-cached-schema-per-type design. The model is the right place, and `bank`'s `loadOwned` guard shows it works. | +| **BPM / BPMN engine** (Axelor, Jmix) | A process engine without a store is meaningless, and morph does not own the store. | +| **Grain call filters / interceptor pipeline** (Orleans) | morph already has validator + authorizer + `observe` + journal on every dispatch path. No observed friction. | +| **Clustering, silos, placement, grain versioning, stateless workers, distributed ACID transactions** (Orleans) | morph is a UI bridge with one server, not a distributed runtime. | +| **Managed streams with cursors and checkpoints** (Orleans) | F3 is best-effort and unbuffered by design; durability here is a distributed-runtime concern. | +| **Reporting engine** (BIRT, JasperReports), **ORM + schema migration**, **IDE Studio** | Out of identity. morph owns neither the store nor the tooling. | +| **Runtime custom fields / dynamic attributes** (Axelor, Jmix) | Hard against compile-time reflection, and no observed need. | +| **REST / GraphQL facade, blob transfer, tabular export, multi-tenancy discriminator** | Plausible, no observed need. Revisit when one exists. | + +## Also surfaced + +- **`README.md`'s "Status & limitations" is stale.** It still claims the wire + protocol has no version negotiation, that `RemoteServer` model ids are + guessable sequential integers, and that only an in-memory offline queue + ships — all fixed by §A/§B below. Worth correcting independently of §F. + +--- + +# Production hardening & GUI generation (shipped) This tracked a design-approved, prioritized checklist of production-hardening -and GUI-generation work. **Every item below has shipped.** The authoritative, -present-tense design for each is in `docs/spec/`; this file is now a changelog -of what landed and why, kept for the rationale (priority, dependency order) -that motivated the work. +and GUI-generation work. **Every item in §A–§E has shipped.** The authoritative, +present-tense design for each is in `docs/spec/`; this section is a record of +what landed and why, kept for the rationale (priority, dependency order) that +motivated the work. Readiness depended on deployment mode: @@ -72,6 +182,10 @@ dropped socket now reclaims its models instead of stranding them until process exit. `closeConnection` is server housekeeping — it bypasses `IAuthorizer` by design, not a synthesized wire `deregister`. See `spec/core/backend.md#connection-scopes`. +> **F2 changes this.** Cross-client instance sharing requires `closeConnection` +> to decrement a reference count rather than erase. See +> [`planned/shared_model_instances.md`](planned/shared_model_instances.md). + --- ## B. Durability & data-integrity (both modes, if you persist) @@ -202,6 +316,9 @@ stays renderer-agnostic) is now documented in `BRIDGE_REGISTER_WIZARD`/`BRIDGE_REGISTER_APP`) and the `src/qt/forms` `WizardView.qml` reference renderer plus the demo's `AppShell.qml`. + > **F3 reworks this.** `FlowSession` is built on the reactive-draft mechanism + > F3 removes; it must be re-expressed as a stateful, keyed flow model first. + ### Ecosystem - **E-G9 — Renderer toolkit** · P1 · shipped — `spec/forms/forms.md` @@ -219,12 +336,11 @@ stays renderer-agnostic) is now documented in ## Notes -- Every item landed opt-in or backward compatible by default — none change - existing behavior unless enabled. -- `docs/planned/` no longer holds any implemented-item specs; the - authoritative current-state specs are entirely in `docs/spec/`. -- Two items surfaced *by* this program, not originally on it, and fixed before - it closed out: C3's fuzz harness found two real bugs in `morph::wire`'s +- Every §A–§E item landed opt-in or backward compatible by default — none change + existing behavior unless enabled. **§F breaks this pattern deliberately:** F2 + changes A7's cleanup semantics and F3 removes a public API. +- Two items surfaced *by* the §A–§E program, not originally on it, and fixed + before it closed out: C3's fuzz harness found two real bugs in `morph::wire`'s glaze-based parsing (a heap-buffer-overflow reachable by a 5-byte input, and a case where `RemoteServer`'s own error reply didn't round-trip through `encode`/`decode`). See `docs/spec/testing_strategy.md`'s "Known findings" From 875e3909d707ed018781a04b6743958676b90284 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 27 Jul 2026 23:14:47 +0200 Subject: [PATCH 02/42] feat(core): keyed, shareable model instances A model declares a nested PrimaryKey alias (detected structurally, like views.md's kind/query); actions declare which field carries it via BRIDGE_KEY_FROM, or that their result establishes it via BRIDGE_KEY_FROM_RESULT. BridgeHandler joins a server-side directory keyed on (typeId, primary), so two handlers -- in one process or in two clients over one RemoteServer -- reach the same instance. - wire: `primary`/`shared` envelope fields plus `attach`, `assign` and `instances` kinds. All additive; a `shared:false` register is unchanged. - RemoteServer: (typeId, primary) directory with a cross-connection attach count. Shared instances are recorded ownerless, because authorizeInstance's documented ownerPrincipal == ctx.principal policy would otherwise reject every client but the creator. - A7 change: closeConnection now releases one reference per attachment rather than erasing, so one client's disconnect cannot destroy an instance another client still holds. Scope membership became a count for the same reason. - A keyed action re-points the handler; instances never change identity, so a key always maps to one instance and no collision case arises. - Result-sourced keys promote the instance the create ran on (assignPrimary) rather than re-pointing to a fresh one, which would strand the new state. BridgeHandler is BridgeHandler and behaves exactly as before; all 770 pre-existing tests pass unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- include/morph/core/backend.hpp | 216 ++++++++++++++++- include/morph/core/bridge.hpp | 289 +++++++++++++++++++++- include/morph/core/model_key.hpp | 160 +++++++++++++ include/morph/core/remote.hpp | 398 +++++++++++++++++++++++++++++-- include/morph/core/wire.hpp | 104 ++++++++ tests/CMakeLists.txt | 1 + tests/test_shared_instances.cpp | 311 ++++++++++++++++++++++++ 7 files changed, 1442 insertions(+), 37 deletions(-) create mode 100644 include/morph/core/model_key.hpp create mode 100644 tests/test_shared_instances.cpp diff --git a/include/morph/core/backend.hpp b/include/morph/core/backend.hpp index 41b2b9fb..353b3907 100644 --- a/include/morph/core/backend.hpp +++ b/include/morph/core/backend.hpp @@ -53,6 +53,22 @@ struct ActionCall { ::morph::session::Context session; }; +/// @brief The two identities a model instance can carry, passed together. +/// +/// Bundled into one struct rather than passed as two adjacent `string_view` +/// parameters because they are trivially swappable at a call site and mean +/// entirely different things: transposing them would silently file journal +/// entries under the directory key and share instances under the log's entity +/// key. Keeping them named at every call site makes that mistake unwritable. +struct InstanceIdentity { + /// @brief Entity key for the action log; empty if none. See `journal::LogEntry::entityKey`. + std::string_view contextKey; + + /// @brief Canonical string encoding of the primary key; empty if the + /// instance is anonymous and therefore unshareable. + std::string_view primary; +}; + /// @brief Abstract interface for execution backends (local, remote, …). /// /// A backend owns model instances and dispatches actions against them. @@ -88,7 +104,103 @@ struct IBackend { return registerModel(typeId, std::move(factory)); } + /// @brief Registers or attaches to the shared instance holding @p primary. + /// + /// A *register-or-attach*: if an instance for `(typeId, primary)` is already + /// live in the backend's shared directory, its id is returned and its attach + /// count incremented — no new instance is created and @p factory is not + /// called. Otherwise a new instance is created, entered in the directory, + /// and returned with an attach count of one. + /// + /// An empty @p primary means "no identity": the call degrades to + /// `registerModelWithContext`, producing a private instance that never + /// enters the directory and can never be shared. + /// + /// The default implementation ignores @p primary and forwards to + /// `registerModelWithContext`, so a backend that has not implemented sharing + /// keeps its existing one-instance-per-caller behaviour rather than silently + /// handing two callers the same instance. + /// + /// @param typeId String type-id of the model. + /// @param factory Callable that constructs the `IModelHolder` (local path only). + /// @param identity Entity key for the action log plus the directory primary key. + /// @return Id of the shared (or newly created) instance. + virtual ::morph::exec::detail::ModelId registerModelShared( + const std::string& typeId, std::function()> factory, + InstanceIdentity identity) { + return registerModelWithContext(typeId, std::move(factory), identity.contextKey); + } + + /// @brief Re-points from @p current to the shared instance holding @p primary. + /// + /// The default implementation releases @p current (when non-zero) and then + /// calls `registerModelShared`, which is exactly right for an in-process + /// backend. Backends behind a wire protocol override this with the single + /// `attach` request so a re-pointing client cannot lose its slot to + /// `LimitPolicy::maxLiveModels` between the release and the acquire. + /// + /// @param typeId String type-id of the model. + /// @param factory Callable that constructs the `IModelHolder` (local path only). + /// @param identity Entity key for the action log plus the directory primary key. + /// @param current Instance currently held, or `ModelId{0}` if none. + /// @return Id of the instance now attached to. + virtual ::morph::exec::detail::ModelId attachModel( + const std::string& typeId, std::function()> factory, + InstanceIdentity identity, ::morph::exec::detail::ModelId current) { + if (current.v != 0U) { + deregisterModel(current); + } + return registerModelShared(typeId, std::move(factory), identity); + } + + /// @brief Enters an already-live instance into the directory under @p primary. + /// + /// The *promotion* half of keyed instances, and what makes a result-sourced + /// key work without losing state: an action that creates its own entity runs + /// on an instance that does not yet have a key, and the key only exists once + /// the result comes back. Re-pointing to a freshly created instance would + /// strand everything the create just did, so instead the instance the action + /// ran on is given the generated key in place. + /// + /// A no-op when @p primary is empty, when @p mid is not live, or when another + /// instance already holds that key — the existing holder always wins, so a + /// promotion can never silently displace a directory entry other handlers + /// are already attached to. + /// + /// @param mid Live instance to promote. + /// @param typeId Model type id — the directory's first key component. + /// @param primary Canonical string encoding of the key to file it under. + virtual void assignPrimary(::morph::exec::detail::ModelId mid, const std::string& typeId, + std::string_view primary) { + (void)mid; + (void)typeId; + (void)primary; + } + + /// @brief Lists the primary keys of live shared instances of @p typeId. + /// + /// Only instances created through `registerModelShared`/`attachModel` with a + /// non-empty primary appear; a private instance is invisible to the + /// directory by construction. The result is a snapshot and is stale the + /// moment it is returned. + /// + /// Synchronous, matching `registerModel`, which already blocks on remote + /// backends. The asynchronous surface users see is + /// `BridgeHandler::instances()`, which wraps this in a `Completion` so the + /// call site reads identically local and remote. + /// + /// @param typeId String type-id to enumerate. + /// @return Canonical key strings of the live shared instances; empty by default. + virtual std::vector listInstances(const std::string& typeId) { + (void)typeId; + return {}; + } + /// @brief Removes the model identified by @p mid from the backend. + /// + /// For a shared instance this *decrements* its attach count and destroys the + /// instance only when the count reaches zero, so one caller releasing an + /// instance never tears it out from under another that is still attached. virtual void deregisterModel(::morph::exec::detail::ModelId mid) = 0; /// @brief Dispatches @p call against the model identified by @p mid. @@ -195,11 +307,100 @@ class LocalBackend : public detail::IBackend { return mid; } - /// @brief Removes the model with @p mid. Thread-safe. - /// @param mid Id returned by a prior `registerModel()` call. + /// @brief Registers or attaches to the shared instance holding @p primary. + /// + /// An empty @p primary bypasses the directory entirely and produces a + /// private instance, exactly as `registerModel` does. + /// @param typeId String type-id of the model — the directory's first key component. + /// @param factory Callable that constructs the `IModelHolder`; not called on an attach. + /// @param identity Entity key for the action log plus the directory primary key. + /// @return Id of the shared (or newly created) instance. + ::morph::exec::detail::ModelId registerModelShared( + const std::string& typeId, std::function()> factory, + detail::InstanceIdentity identity) override { + if (identity.primary.empty()) { + return registerModelWithContext(typeId, std::move(factory), identity.contextKey); + } + ::morph::observe::detail::emitMetric(::morph::observe::Metric::registerCount, 1.0); + DirectoryKey dirKey{typeId, std::string{identity.primary}}; + std::scoped_lock const lock{_regMtx}; + if (auto found = _directory.find(dirKey); found != _directory.end()) { + _attachCount[found->second] += 1; + return found->second; + } + ::morph::exec::detail::ModelId const mid{_nextId.fetch_add(1) + 1}; + auto holder = factory(); + if (holder->isBackendChangeAware()) { + _changeAware.insert(mid); + } + _models[mid] = std::move(holder); + _directory.emplace(dirKey, mid); + _sharedKeyOf.emplace(mid, std::move(dirKey)); + _attachCount[mid] = 1; + return mid; + } + + /// @brief Enters an already-live instance into the directory under @p primary. Thread-safe. + /// @param mid Live instance to promote. + /// @param typeId Model type id — the directory's first key component. + /// @param primary Canonical string encoding of the key to file it under. + void assignPrimary(::morph::exec::detail::ModelId mid, const std::string& typeId, + std::string_view primary) override { + if (primary.empty()) { + return; + } + std::scoped_lock const lock{_regMtx}; + if (!_models.contains(mid)) { + return; + } + DirectoryKey dirKey{typeId, std::string{primary}}; + if (_directory.contains(dirKey)) { + return; + } + if (auto prevIter = _sharedKeyOf.find(mid); prevIter != _sharedKeyOf.end()) { + _directory.erase(prevIter->second); + _sharedKeyOf.erase(prevIter); + } + _directory.emplace(dirKey, mid); + _sharedKeyOf.emplace(mid, std::move(dirKey)); + _attachCount.try_emplace(mid, 1); + } + + /// @brief Lists the primary keys of live shared instances of @p typeId. Thread-safe. + /// @param typeId String type-id to enumerate. + /// @return Canonical key strings of the live shared instances, in unspecified order. + std::vector listInstances(const std::string& typeId) override { + std::vector keys; + std::scoped_lock const lock{_regMtx}; + for (const auto& [dirKey, mid] : _directory) { + if (dirKey.first == typeId) { + keys.push_back(dirKey.second); + } + } + return keys; + } + + /// @brief Removes the model with @p mid, or releases one attachment to it. Thread-safe. + /// + /// A private instance is erased outright. A shared instance has its attach + /// count decremented and is erased — and removed from the directory — only + /// when that count reaches zero, so releasing one handler never destroys an + /// instance another handler still holds. + /// @param mid Id returned by a prior `registerModel()`/`registerModelShared()` call. void deregisterModel(::morph::exec::detail::ModelId mid) override { ::morph::observe::detail::emitMetric(::morph::observe::Metric::deregisterCount, 1.0); std::scoped_lock const lock{_regMtx}; + if (auto refIter = _attachCount.find(mid); refIter != _attachCount.end()) { + refIter->second -= 1; + if (refIter->second > 0) { + return; + } + _attachCount.erase(refIter); + if (auto keyIter = _sharedKeyOf.find(mid); keyIter != _sharedKeyOf.end()) { + _directory.erase(keyIter->second); + _sharedKeyOf.erase(keyIter); + } + } _models.erase(mid); _changeAware.erase(mid); } @@ -367,6 +568,17 @@ class LocalBackend : public detail::IBackend { // `notifyBackendChanged()` never needs to inspect a model it doesn't have // to. Always a subset of `_models`' keys. std::unordered_set<::morph::exec::detail::ModelId, ::morph::exec::detail::ModelIdHash> _changeAware; + // Shared-instance directory: (typeId, primary) -> ModelId, plus the reverse + // lookup and per-instance attach count. All three are maintained under + // _regMtx alongside _models, so directory membership can never desync from + // instance existence — the same invariant RemoteServer's connection scopes + // maintain. Only instances registered with a non-empty primary appear here; + // a private instance has no entry in any of the three, which is exactly what + // makes deregisterModel's decrement path a no-op for it. + using DirectoryKey = std::pair; + std::unordered_map _directory; + std::unordered_map<::morph::exec::detail::ModelId, DirectoryKey, ::morph::exec::detail::ModelIdHash> _sharedKeyOf; + std::unordered_map<::morph::exec::detail::ModelId, std::size_t, ::morph::exec::detail::ModelIdHash> _attachCount; std::atomic _nextId{0}; std::mutex _pendingMtx; std::vector>>> _pending; diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index bc1f61b0..830045e7 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -7,7 +7,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -18,6 +20,7 @@ #include "../session/session.hpp" #include "backend.hpp" #include "completion.hpp" +#include "model_key.hpp" #include "registry.hpp" namespace morph::bridge { @@ -145,6 +148,21 @@ struct HandlerBinding { /// can attach a log to the instance it creates. See `IBackend::registerModelWithContext`. std::string contextKey; + /// @brief Canonical string encoding of this instance's primary key. + /// + /// Empty until a keyed action or an explicit `attach` supplies one. Only + /// meaningful when `shared` is set; a private binding never consults it. + /// Mutated only under `Bridge::_mtx`. + std::string primary; + + /// @brief Whether this binding participates in the shared instance directory. + /// + /// Set once at construction from `BridgeHandler`'s `Sharing` template + /// argument and never changed. A shared binding defers its backend + /// registration until it has a primary, so `currentId` stays `0` — and + /// `executeVia` fails fast on "handler not bound" — until then. + bool shared = false; + /// @brief Current `ModelId` value in the active backend (0 = unbound). std::atomic currentId{0}; }; @@ -229,6 +247,108 @@ class Bridge { _handlers.push_back(binding); } + /// @brief Creates a shared, initially **unattached** binding for `Model`. + /// + /// Unlike `registerHandler()`, this registers nothing on the backend: + /// a shared handler has no instance until a keyed action or an explicit + /// `attach` tells it which one it wants. The binding is tracked from the + /// start so `switchBackend()` knows about it, but it stays unbound + /// (`currentId == 0`) until then. + /// + /// @tparam Model Concrete model type. Must have a registered `ModelTraits`. + /// @return Shared pointer to the new, unattached binding. + template + std::shared_ptr registerSharedHandler() { + auto binding = std::make_shared(); + binding->typeId = std::string{::morph::model::ModelTraits::typeId()}; + binding->modelFactory = [] { return ::morph::model::detail::ModelFactory::create(); }; + binding->shared = true; + std::scoped_lock const lock{_mtx}; + _handlers.push_back(binding); + return binding; + } + + /// @brief Attaches (or re-points) @p binding to the shared instance for @p primary. + /// + /// Idempotent: attaching to the primary a binding already holds is a no-op, + /// so a keyed action repeated against the same instance costs nothing. A + /// different primary re-points the binding — the previous instance is + /// released and survives only if another handler still holds it. + /// + /// The binding's `contextKey` is set to @p primary as well, so a keyed + /// model's journal entries carry the entity key without the caller + /// arranging it separately. + /// + /// @tparam Model Concrete model type. + /// @param binding Shared binding, as returned by `registerSharedHandler()`. + /// @param primary Canonical string encoding of the primary key to attach to. + template + void attachHandler(const std::shared_ptr& binding, std::string primary) { + std::scoped_lock const lock{_mtx}; + if (binding->primary == primary && binding->currentId.load() != 0U) { + return; + } + binding->contextKey = primary; + auto newId = loadBackend()->attachModel(binding->typeId, binding->modelFactory, + {.contextKey = binding->contextKey, .primary = primary}, + ::morph::exec::detail::ModelId{binding->currentId.load()}); + binding->primary = std::move(primary); + binding->currentId.store(newId.v); + } + + /// @brief Gives @p binding an anonymous instance if it does not have one yet. + /// + /// Used before a result-keyed action: such an action generates the key it + /// will be filed under, so it has to run *somewhere* first. The instance it + /// gets is private (empty primary, invisible to the directory) until + /// `assignHandlerPrimary` promotes it in place once the key is known. + /// @param binding Shared binding to bind. + void ensureBound(const std::shared_ptr& binding) { + std::scoped_lock const lock{_mtx}; + if (binding->currentId.load() != 0U) { + return; + } + auto newId = loadBackend()->registerModelShared(binding->typeId, binding->modelFactory, + {.contextKey = binding->contextKey, .primary = {}}); + binding->currentId.store(newId.v); + } + + /// @brief Files @p binding's current instance under @p primary, in place. + /// + /// The instance keeps everything the creating action just did — nothing is + /// re-created and nothing is stranded. A no-op if another instance already + /// holds that key; the existing holder always wins. + /// @tparam Model Concrete model type. + /// @param binding Shared binding whose instance is being promoted. + /// @param primary Canonical string encoding of the key to file it under. + template + void assignHandlerPrimary(const std::shared_ptr& binding, std::string primary) { + std::scoped_lock const lock{_mtx}; + uint64_t const raw = binding->currentId.load(); + if (raw == 0U || primary.empty()) { + return; + } + loadBackend()->assignPrimary(::morph::exec::detail::ModelId{raw}, binding->typeId, primary); + binding->contextKey = primary; + binding->primary = std::move(primary); + } + + /// @brief Returns @p binding's current primary key, or empty if unattached. + /// @param binding Binding to inspect. + /// @return Canonical key string, or an empty string when unattached. + [[nodiscard]] std::string bindingPrimary(const std::shared_ptr& binding) { + std::scoped_lock const lock{_mtx}; + return binding->primary; + } + + /// @brief Lists the live shared primary keys of `Model` on the active backend. + /// @tparam Model Concrete model type. + /// @return Canonical key strings, in unspecified order. + template + [[nodiscard]] std::vector listInstancesOf() { + return loadBackend()->listInstances(std::string{::morph::model::ModelTraits::typeId()}); + } + /// @brief Installs a default session context that `executeVia` stamps onto the /// `ActionCall` of every subsequent call. /// @@ -291,8 +411,19 @@ class Bridge { if (!binding) { continue; } - auto newId = newShared->registerModelWithContext(binding->typeId, binding->modelFactory, - binding->contextKey); + // A shared binding that never attached has no instance to + // re-create: it stays live and unbound, and acquires one on + // the new backend the first time it is attached. + if (binding->shared && binding->primary.empty()) { + live.push_back(weak); + continue; + } + auto newId = binding->shared + ? newShared->registerModelShared( + binding->typeId, binding->modelFactory, + {.contextKey = binding->contextKey, .primary = binding->primary}) + : newShared->registerModelWithContext(binding->typeId, binding->modelFactory, + binding->contextKey); staged.emplace_back(binding, newId.v); live.push_back(weak); } @@ -381,7 +512,8 @@ class Bridge { /// fails its validator). template ::morph::async::Completion::Result> executeVia( - const std::shared_ptr& binding, Action action, ::morph::exec::IExecutor* cbExec) { + const std::shared_ptr& binding, Action action, ::morph::exec::IExecutor* cbExec, + std::function::Result&)> onResult = {}) { using R = ::morph::model::ActionTraits::Result; auto backend = loadBackend(); @@ -459,7 +591,7 @@ class Bridge { } auto anyCompletion = backend->execute(::morph::exec::detail::ModelId{raw}, std::move(call), cbExec); anyCompletion - .then([typedState](const std::shared_ptr& vAny) { + .then([typedState, onResult = std::move(onResult)](const std::shared_ptr& vAny) { // Guard the value-forwarding: if R's move/copy throws (or the cast // is somehow wrong), route the exception to the typed completion's // error sink instead of letting it escape the callback executor — @@ -468,7 +600,14 @@ class Bridge { // and std::terminate. Mirrors the forwarding guard in remote.hpp's // SimulatedRemoteBackend::execute. See docs/spec/bridge.md. try { - typedState->setValue(std::move(*static_cast(vAny.get()))); + auto* const typedResult = static_cast(vAny.get()); + // Runs before the value is moved out and before the caller's + // own .then, so a result-sourced primary key is adopted by + // the binding before any user code observes the result. + if (onResult) { + onResult(*typedResult); + } + typedState->setValue(std::move(*typedResult)); } catch (...) { typedState->setException(std::current_exception()); } @@ -478,7 +617,7 @@ class Bridge { } private: - template + template friend class BridgeHandler; /// @brief Weak observer of this bridge's lifetime, handed to each handler. @@ -548,6 +687,28 @@ class Bridge { std::shared_ptr _liveness{std::make_shared()}; }; +/// @brief `BridgeHandler` sharing policy: private, one instance per handler. +/// +/// The default, and what every pre-existing call site gets. Such a handler +/// registers its own instance at construction and never enters the shared +/// directory, so two `BridgeHandler` objects are two independent models — +/// byte-for-byte the behaviour morph has always had. +struct NoSharing {}; + +/// @brief `BridgeHandler` sharing policy: joins the shared instance directory. +/// +/// A shared handler registers **nothing** at construction. It acquires an +/// instance the first time a keyed action or an explicit `attach()` names a +/// primary key, and every other `AllowShared` handler naming that same key — +/// in this process or, for a remote backend, in any other client — reaches the +/// same instance. Releasing the last such handler destroys it. +/// +/// A shared handler that only ever runs *keyless* actions never attaches, and +/// its `execute` fails fast with "handler not bound": there is no instance to +/// run against and inventing a private one would silently defeat the sharing +/// the caller asked for. Attach first — see docs/planned/shared_model_instances.md. +struct AllowShared {}; + /// @brief RAII wrapper that binds a single model type to a `Bridge`. /// /// On construction, registers a `HandlerBinding` on the bridge. On destruction, @@ -564,10 +725,13 @@ class Bridge { /// - `reset()` discards the in-progress draft of `A`. /// /// @tparam Model Concrete model type. -template +template // NOLINTNEXTLINE(cppcoreguidelines-special-member-functions) class BridgeHandler { public: + /// @brief Whether this handler participates in the shared instance directory. + static constexpr bool kShared = std::is_same_v; + /// @brief Constructs and registers the handler using the default model factory. /// /// @param bridge The bridge to register on. @@ -576,8 +740,10 @@ class BridgeHandler { : _bridge{bridge}, _bridgeAlive{bridge.liveness()}, _guiExec{guiExec}, - _binding{bridge.template registerHandler()}, + _binding{makeBinding(bridge)}, _subs{std::make_shared()} { + static_assert(!kShared || ::morph::model::KeyedModel, + "BridgeHandler requires Model to declare a PrimaryKey alias"); _subs->bridge = &_bridge; _subs->binding = _binding; _subs->guiExec = _guiExec; @@ -627,7 +793,101 @@ class BridgeHandler { /// @return Completion that resolves on the GUI executor. template ::morph::async::Completion::Result> execute(Action action) { - return _bridge.template executeVia(_binding, std::move(action), _guiExec); + if constexpr (kShared && ::morph::model::detail::PayloadKeyed) { + // The action names its instance: attach (or re-point) before + // dispatching, so the call lands on the instance it asked for. + _bridge.template attachHandler(_binding, ::morph::model::ActionKeyTraits::key(action)); + } + if constexpr (kShared && ::morph::model::detail::ResultKeyed) { + // The action *creates* the instance and its result carries the + // generated key, exactly as a database insert returns its primary + // key. Adopt it before any user callback observes the result, so a + // .then() can immediately run further actions on the new instance. + using R = ::morph::model::ActionTraits::Result; + // The action generates its own key, so it must run before the key + // exists. Give the handler an anonymous instance to run on, then + // promote *that* instance once the reply names it — re-pointing to a + // fresh one instead would strand whatever the create just did. + _bridge.ensureBound(_binding); + auto* const bridgePtr = &_bridge; + auto binding = _binding; + return _bridge.template executeVia( + _binding, std::move(action), _guiExec, [bridgePtr, binding](const R& result) { + bridgePtr->template assignHandlerPrimary( + binding, ::morph::model::ActionKeyTraits::template keyOfResult(result)); + }); + } else { + return _bridge.template executeVia(_binding, std::move(action), _guiExec); + } + } + + /// @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 + /// existing one. Re-pointing an already-attached handler releases the old + /// instance, which survives only if another handler still holds it. + /// + /// The primary is deliberately **not** write-once: naming a different key is + /// how a screen switches which entity it is looking at. Instances never + /// change their own identity — the *handler* moves — so a key always maps to + /// exactly one instance and no collision case can arise. + /// + /// @tparam M Defaulted to `Model`; never named explicitly. Present only so the + /// signature is instantiated lazily, since `PrimaryKeyOf` is + /// ill-formed for an unkeyed model. + /// @param key Primary key of the instance to attach to. + template + void attach(const ::morph::model::PrimaryKeyOf& key) + requires kShared + { + _bridge.template attachHandler(_binding, ::morph::model::keyToString(key)); + } + + /// @brief This handler's current primary key, or `nullopt` if unattached. + /// @tparam M Defaulted to `Model`; never named explicitly. See `attach`. + /// @return The attached key, or `nullopt` before the first attach. + template + [[nodiscard]] std::optional<::morph::model::PrimaryKeyOf> primary() + requires kShared + { + auto raw = _bridge.bindingPrimary(_binding); + if (raw.empty()) { + return std::nullopt; + } + return ::morph::model::keyFromString<::morph::model::PrimaryKeyOf>(raw); + } + + /// @brief Snapshot of the live shared instance keys for `Model`. + /// + /// Asynchronous even in local mode: the directory is backend state, and in + /// remote mode answering costs a round trip. Returning a bare `std::vector` + /// would work in-process and force a different call site everywhere else, + /// breaking the local/remote symmetry the framework is built on. + /// + /// The result is a snapshot, stale the moment it arrives — another client may + /// attach or release before the callback runs. Treat a returned key as "was + /// live recently", never as a guarantee that a later `attach` finds the same + /// instance. + /// + /// @tparam M Defaulted to `Model`; never named explicitly. See `attach`. + /// @return Completion resolving on the GUI executor with the live keys. + template + [[nodiscard]] ::morph::async::Completion>> instances() + requires kShared + { + using Key = ::morph::model::PrimaryKeyOf; + auto state = std::make_shared<::morph::async::detail::CompletionState>>(); + ::morph::async::Completion> comp{state, _guiExec}; + try { + std::vector keys; + for (const auto& raw : _bridge.template listInstancesOf()) { + keys.push_back(::morph::model::keyFromString(raw)); + } + state->setValue(std::move(keys)); + } catch (...) { + state->setException(std::current_exception()); + } + return comp; } /// @brief Type-erased execute: looks up the action by its registered @@ -648,6 +908,17 @@ class BridgeHandler { actionType, this, bodyJson); } + /// @brief Creates this handler's binding: deferred when shared, immediate otherwise. + /// @param bridge Bridge to create the binding on. + /// @return The new binding. + static std::shared_ptr makeBinding(Bridge& bridge) { + if constexpr (kShared) { + return bridge.template registerSharedHandler(); + } else { + return bridge.template registerHandler(); + } + } + /// @brief The executor used to deliver this handler's `Completion` callbacks. /// @return The GUI/callback executor passed at construction. [[nodiscard]] ::morph::exec::IExecutor* guiExecutor() const noexcept { return _guiExec; } diff --git a/include/morph/core/model_key.hpp b/include/morph/core/model_key.hpp new file mode 100644 index 00000000..30f191d1 --- /dev/null +++ b/include/morph/core/model_key.hpp @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include + +/// @file +/// Primary keys for model instances. +/// +/// A model type declares itself *keyed* by exposing a nested `PrimaryKey` type +/// alias; the alias is detected structurally (a `requires`-expression), never by +/// inheritance or a marker base — the same detection style +/// `morph::views::ViewTraits` uses for a view's `kind`/`query` members. A model +/// without the alias is unkeyed and behaves exactly as it always has. +/// +/// Actions say *which of their fields carries* that key via `BRIDGE_KEY_FROM`, +/// or — for an action that creates the entity rather than naming it — which +/// field of their *result* establishes it, via `BRIDGE_KEY_FROM_RESULT`. Actions +/// with neither declaration are keyless and run against whichever instance the +/// handler is already attached to; that is the common case. +/// +/// Keys travel the wire as strings (`wire::Envelope::primary`) regardless of +/// their C++ type, so the directory in `RemoteServer` needs exactly one map type +/// rather than one per key type. See docs/planned/shared_model_instances.md. + +namespace morph::model { + +/// @brief Key types a model may declare as its `PrimaryKey`. +/// +/// Restricted to integral types and `std::string` because a key must round-trip +/// losslessly through the wire's string encoding and be usable as a map key. +/// `bool` is excluded: it carries one bit of identity, which is never a +/// meaningful primary key and is far more likely to be a mistake. +template +concept ModelKey = + (std::integral && !std::same_as, bool>) || std::same_as, std::string>; + +/// @brief Satisfied by model types that declare a `PrimaryKey` alias. +/// +/// Declaring the alias is what opts a model into keyed, shareable instances. +template +concept KeyedModel = requires { typename M::PrimaryKey; } && ModelKey; + +/// @brief The declared key type of a keyed model. +/// @tparam M Keyed model type. +template +using PrimaryKeyOf = typename M::PrimaryKey; + +/// @brief Encodes a primary key as its canonical wire string. +/// +/// Integral keys are decimal; `std::string` keys pass through unchanged. The +/// encoding is total — every valid key has exactly one representation — so two +/// clients naming the same key always land on the same directory entry. +/// @tparam K Key type satisfying `ModelKey`. +/// @param key Key value to encode. +/// @return The canonical string form of @p key. +template +[[nodiscard]] std::string keyToString(const K& key) { + if constexpr (std::same_as, std::string>) { + return key; + } else { + return std::to_string(key); + } +} + +/// @brief Decodes a primary key from its canonical wire string. +/// +/// @tparam K Key type satisfying `ModelKey`. +/// @param text Canonical string form, as produced by `keyToString`. +/// @return The decoded key. +/// @throws std::runtime_error if @p text is not a valid encoding of a `K` +/// (non-numeric text, trailing garbage, or a value out of range). A key +/// that cannot be decoded is a protocol error, not a value to clamp: +/// silently yielding 0 would route the caller to the wrong instance. +template +[[nodiscard]] K keyFromString(std::string_view text) { + if constexpr (std::same_as, std::string>) { + return std::string{text}; + } else { + K value{}; + const auto* const first = text.data(); + const auto* const last = first + text.size(); + auto [ptr, errc] = std::from_chars(first, last, value); + if (errc != std::errc{} || ptr != last) { + throw std::runtime_error("invalid primary key encoding: '" + std::string{text} + "'"); + } + return value; + } +} + +/// @brief Declares where an action's model key comes from. +/// +/// The primary template is the *keyless* case, which is the default and the +/// common one: such an action says nothing about identity and runs against +/// whichever instance its handler is already attached to. Specialise via +/// `BRIDGE_KEY_FROM` or `BRIDGE_KEY_FROM_RESULT` rather than by hand. +/// @tparam Action Concrete action type. +template +struct ActionKeyTraits { + /// @brief Whether this action carries or establishes its model's key. + static constexpr bool hasKey = false; + + /// @brief Whether the key comes from the action's result rather than its payload. + static constexpr bool fromResult = false; +}; + +namespace detail { + +/// @brief Satisfied by actions whose key is carried in the action payload. +template +concept PayloadKeyed = ActionKeyTraits::hasKey && !ActionKeyTraits::fromResult; + +/// @brief Satisfied by actions whose key is established by the action's result. +template +concept ResultKeyed = ActionKeyTraits::hasKey && ActionKeyTraits::fromResult; + +} // namespace detail + +} // namespace morph::model + +/// @brief Declares that action `A` carries its model's primary key in `MEMBER`. +/// +/// `MEMBER` is a pointer-to-data-member of `A` (e.g. `&GetAccount::id`) whose +/// type satisfies `morph::model::ModelKey`. Executing such an action on a +/// shareable handler attaches (or re-points) that handler to the instance +/// holding the named key, creating it if no instance holds it yet. +/// +/// Must appear at global scope, in exactly one translation unit, like the other +/// `BRIDGE_REGISTER_*` macros. +#define BRIDGE_KEY_FROM(A, MEMBER) \ + template <> \ + struct morph::model::ActionKeyTraits { \ + static constexpr bool hasKey = true; \ + static constexpr bool fromResult = false; \ + static std::string key(const A& action) { return morph::model::keyToString(action.*MEMBER); } \ + } + +/// @brief Declares that action `A`'s *result* establishes its model's primary key. +/// +/// For actions that create the entity rather than name it: the key is not in the +/// request, it is generated and returned, exactly as a database insert returns +/// its generated primary key. `MEMBER` is a pointer-to-data-member of `A`'s +/// result type (e.g. `&AccountInfo::id`). +/// +/// Must appear at global scope, in exactly one translation unit. +#define BRIDGE_KEY_FROM_RESULT(A, MEMBER) \ + template <> \ + struct morph::model::ActionKeyTraits { \ + static constexpr bool hasKey = true; \ + static constexpr bool fromResult = true; \ + template \ + static std::string keyOfResult(const R& result) { \ + return morph::model::keyToString(result.*MEMBER); \ + } \ + } diff --git a/include/morph/core/remote.hpp b/include/morph/core/remote.hpp index db06ba6f..2a37aa20 100644 --- a/include/morph/core/remote.hpp +++ b/include/morph/core/remote.hpp @@ -428,9 +428,14 @@ class RemoteServer : public std::enable_shared_from_this { if (scopeIter == _connectionScopes.end()) { return; } - for (const auto& mid : scopeIter->second) { - _models.erase(mid); - _owners.erase(mid); + for (const auto& [mid, refs] : scopeIter->second) { + // Release exactly as many references as this connection held. A + // shared instance another connection is still attached to survives; + // a private one (count 1, no directory entry) is erased outright, + // which is byte-for-byte the previous behaviour. + for (std::size_t idx = 0; idx < refs; ++idx) { + releaseInstanceLocked(mid); + } _modelConnection.erase(mid); } _connectionScopes.erase(scopeIter); @@ -582,6 +587,205 @@ class RemoteServer : public std::enable_shared_from_this { } private: + /// @brief Releases one reference to @p mid, destroying it at zero. Caller holds `_regMtx`. + /// + /// A private instance has no `_attachCount` entry and is erased outright — + /// byte-for-byte the pre-sharing behaviour. A shared instance is erased, and + /// removed from the directory, only when its last attachment goes away, so + /// one client's `deregister` or dropped connection never tears an instance + /// out from under another client still using it. + /// @param mid Instance to release. + /// @return `true` if this call destroyed the instance. + bool releaseInstanceLocked(::morph::exec::detail::ModelId mid) { + if (auto refIter = _attachCount.find(mid); refIter != _attachCount.end()) { + refIter->second -= 1; + if (refIter->second > 0) { + return false; + } + _attachCount.erase(refIter); + if (auto keyIter = _sharedKeyOf.find(mid); keyIter != _sharedKeyOf.end()) { + _directory.erase(keyIter->second); + _sharedKeyOf.erase(keyIter); + } + } + _models.erase(mid); + _owners.erase(mid); + return true; + } + + /// @brief Drops one of @p cid's references to @p mid, then releases the instance. + /// Caller holds `_regMtx`. + /// @param mid Instance to release. + /// @param cid Connection whose reference is being dropped; `0` for unscoped. + void releaseScopedLocked(::morph::exec::detail::ModelId mid, ConnectionId cid) { + if (cid != 0) { + if (auto scopeIter = _connectionScopes.find(cid); scopeIter != _connectionScopes.end()) { + if (auto refIter = scopeIter->second.find(mid); refIter != scopeIter->second.end()) { + refIter->second -= 1; + if (refIter->second == 0) { + scopeIter->second.erase(refIter); + } + } + } + } + if (releaseInstanceLocked(mid)) { + _modelConnection.erase(mid); + } + } + + /// @brief Records a new attachment of @p mid to @p cid. Caller holds `_regMtx`. + /// @param mid Instance being attached. + /// @param cid Connection attaching it; `0` for unscoped (records nothing). + /// @return `false` if @p cid's scope was already closed, in which case nothing was recorded. + bool noteScopeAttachLocked(::morph::exec::detail::ModelId mid, ConnectionId cid) { + if (cid == 0) { + return true; + } + auto scopeIter = _connectionScopes.find(cid); + if (scopeIter == _connectionScopes.end()) { + return false; + } + scopeIter->second[mid] += 1; + _modelConnection[mid] = cid; + return true; + } + + /// @brief Attaches a configured `LogProvider`'s log to a freshly created holder. + /// @param holder Newly created instance. + /// @param env Envelope carrying `typeId` and `contextKey`. + void attachLogIfConfigured(::morph::model::detail::IModelHolder& holder, const ::morph::wire::Envelope& env) { + if (env.contextKey.empty()) { + return; + } + LogProvider provider; + { + std::scoped_lock const lock{_logProviderMtx}; + provider = _logProvider; + } + if (provider) { + if (auto log = provider(env.typeId, env.contextKey)) { + holder.attachActionLog(std::move(log), env.contextKey); + } + } + } + + /// @brief Acquires (or creates) the shared instance for `(typeId, primary)` and replies. + /// + /// The register-or-attach core shared by the `register` branch (when + /// `shared` is set) and by `attach`. A shared instance is recorded with an + /// **empty owner principal**: `IAuthorizer::authorizeInstance`'s documented + /// `ownerPrincipal == ctx.principal` policy would otherwise reject every + /// client but the one that created it, defeating cross-client sharing + /// outright. Gating access to a shared model is therefore `authorize`'s job + /// (per type and action) or the model's own — see docs/spec/security.md. + /// + /// @param env Decoded request; uses `typeId`, `primary`, `contextKey`, `callId`. + /// @param reply Reply sink; always invoked exactly once. + /// @param cid Connection scope, or `0` for unscoped. + /// @param releaseCurrent Instance to release first (an `attach` re-point), or `ModelId{0}`. + void acquireSharedInstance(const ::morph::wire::Envelope& env, const std::function& reply, + ConnectionId cid, ::morph::exec::detail::ModelId releaseCurrent) { + LimitPolicy limits; + { + std::scoped_lock const lock{_limitsMtx}; + limits = _limits; + } + DirectoryKey dirKey{env.typeId, env.primary}; + { + std::scoped_lock const lock{_regMtx}; + if (releaseCurrent.v != 0U) { + releaseScopedLocked(releaseCurrent, cid); + } + if (auto found = _directory.find(dirKey); found != _directory.end()) { + auto const mid = found->second; + _attachCount[mid] += 1; + if (!noteScopeAttachLocked(mid, cid)) { + releaseInstanceLocked(mid); + reply(::morph::wire::encode(::morph::wire::makeErr("connection closed", env.callId))); + return; + } + reply(::morph::wire::encode(::morph::wire::makeOk(env.callId, {}, mid.v))); + return; + } + } + // Directory miss. Construct outside the lock, exactly as the private + // register path does, then re-check under the insert lock: a concurrent + // request for the same key may have won the race while we built ours. + auto holder = _registry.create(env.typeId); + attachLogIfConfigured(*holder, env); + ::morph::exec::detail::ModelId const fresh{nextOpaqueId()}; + { + std::scoped_lock const lock{_regMtx}; + if (auto found = _directory.find(dirKey); found != _directory.end()) { + auto const mid = found->second; + _attachCount[mid] += 1; + if (!noteScopeAttachLocked(mid, cid)) { + releaseInstanceLocked(mid); + reply(::morph::wire::encode(::morph::wire::makeErr("connection closed", env.callId))); + return; + } + reply(::morph::wire::encode(::morph::wire::makeOk(env.callId, {}, mid.v))); + return; + } + if (limits.maxLiveModels != 0 && _models.size() >= limits.maxLiveModels) { + reply(::morph::wire::encode(::morph::wire::makeErr("too many models", env.callId))); + return; + } + if (!noteScopeAttachLocked(fresh, cid)) { + reply(::morph::wire::encode(::morph::wire::makeErr("connection closed", env.callId))); + return; + } + _models[fresh] = std::move(holder); + _owners[fresh] = std::string{}; // shared instances are ownerless, by design + _directory.emplace(dirKey, fresh); + _sharedKeyOf.emplace(fresh, std::move(dirKey)); + _attachCount[fresh] = 1; + } + reply(::morph::wire::encode(::morph::wire::makeOk(env.callId, {}, fresh.v))); + } + + /// @brief Files a live instance under a primary key, in place. + /// + /// The existing holder of a key always wins: promoting onto a key another + /// instance already holds is a silent no-op rather than a displacement, so a + /// promotion can never steal an entry handlers are already attached to. + /// @param env Decoded request; uses `typeId`, `primary`, `modelId`. + void applyAssignLocked(const ::morph::wire::Envelope& env) { + ::morph::exec::detail::ModelId const mid{env.modelId}; + if (env.primary.empty() || !_models.contains(mid)) { + return; + } + DirectoryKey dirKey{env.typeId, env.primary}; + if (_directory.contains(dirKey)) { + return; + } + if (auto prevIter = _sharedKeyOf.find(mid); prevIter != _sharedKeyOf.end()) { + _directory.erase(prevIter->second); + _sharedKeyOf.erase(prevIter); + } + _directory.emplace(dirKey, mid); + _sharedKeyOf.emplace(mid, std::move(dirKey)); + _attachCount.try_emplace(mid, 1); + } + + /// @brief Answers an `instances` request with the live shared keys of a type. + /// @param env Decoded request; uses `typeId` and `callId`. + /// @param reply Reply sink; always invoked exactly once. + void handleInstances(const ::morph::wire::Envelope& env, const std::function& reply) { + std::vector keys; + { + std::scoped_lock const lock{_regMtx}; + for (const auto& [dirKey, mid] : _directory) { + if (dirKey.first == env.typeId) { + keys.push_back(dirKey.second); + } + } + } + std::string body; + (void)glz::write_json(keys, body); + reply(::morph::wire::encode(::morph::wire::makeOk(env.callId, std::move(body)))); + } + // One flat switch over the wire's `kind` discriminator. Splitting it would // scatter the authorization sequence each branch depends on across helpers, // with no reader benefit. @@ -598,7 +802,8 @@ class RemoteServer : public std::enable_shared_from_this { // the existing register/execute validation runs — while `deregister` // (and any other kind) still flows through unchanged, so a client can // still tear its models down cleanly during the drain window. - if ((env.kind == "register" || env.kind == "execute") && _shuttingDown.load(std::memory_order_acquire)) { + if ((env.kind == "register" || env.kind == "execute" || env.kind == "attach") && + _shuttingDown.load(std::memory_order_acquire)) { reply(::morph::wire::encode(::morph::wire::makeErr("server shutting down", env.callId))); return; } @@ -645,19 +850,15 @@ class RemoteServer : public std::enable_shared_from_this { reply(::morph::wire::encode(::morph::wire::makeErr("unauthorized", env.callId))); return; } - auto holder = _registry.create(env.typeId); - if (!env.contextKey.empty()) { - LogProvider provider; - { - std::scoped_lock const lock{_logProviderMtx}; - provider = _logProvider; - } - if (provider) { - if (auto log = provider(env.typeId, env.contextKey)) { - holder->attachActionLog(std::move(log), env.contextKey); - } - } + // A `shared` register naming a primary is a register-or-attach + // against the directory; everything below is the private path, + // unchanged. + if (env.shared && !env.primary.empty()) { + acquireSharedInstance(env, reply, cid, ::morph::exec::detail::ModelId{0}); + return; } + auto holder = _registry.create(env.typeId); + attachLogIfConfigured(*holder, env); // Record the owner principal for per-instance authorization: // env.session's principal is already the verified identity // stamped above (empty if the authorizer does not @@ -702,7 +903,7 @@ class RemoteServer : public std::enable_shared_from_this { if (scopeIter == _connectionScopes.end()) { scopeAlreadyClosed = true; } else { - scopeIter->second.insert(mid); + scopeIter->second[mid] += 1; _modelConnection[mid] = cid; } } @@ -725,6 +926,47 @@ class RemoteServer : public std::enable_shared_from_this { return; } reply(::morph::wire::encode(::morph::wire::makeOk(env.callId, {}, mid.v))); + } else if (env.kind == "attach") { + if (env.typeId.empty()) { + throw std::runtime_error("attach requires a typeId"); + } + if (auto verified = _authorizer->authenticate(env.session)) { + env.session.principal = std::move(*verified); + } else { + env.session.principal.clear(); + } + if (!_authorizer->authorizeRegister(env.session, env.typeId)) { + reply(::morph::wire::encode(::morph::wire::makeErr("unauthorized", env.callId))); + return; + } + acquireSharedInstance(env, reply, cid, ::morph::exec::detail::ModelId{env.modelId}); + } else if (env.kind == "assign") { + if (env.typeId.empty()) { + throw std::runtime_error("assign requires a typeId"); + } + { + std::scoped_lock const lock{_regMtx}; + applyAssignLocked(env); + } + reply(::morph::wire::encode(::morph::wire::makeOk(env.callId, {}, env.modelId))); + } else if (env.kind == "instances") { + if (env.typeId.empty()) { + throw std::runtime_error("instances requires a typeId"); + } + if (auto verified = _authorizer->authenticate(env.session)) { + env.session.principal = std::move(*verified); + } else { + env.session.principal.clear(); + } + // Enumeration is a read channel over the directory: gate it with + // `authorize` for the model type (empty action id) so a deployer + // can refuse listing without refusing use. It discloses the live + // key set to anyone admitted — see docs/spec/security.md. + if (!_authorizer->authorize(env.session, env.typeId, {})) { + reply(::morph::wire::encode(::morph::wire::makeErr("unauthorized", env.callId))); + return; + } + handleInstances(env, reply); } else if (env.kind == "deregister") { ::morph::observe::detail::emitMetric(::morph::observe::Metric::deregisterCount, 1.0); ::morph::exec::detail::ModelId const mid{env.modelId}; @@ -749,17 +991,25 @@ class RemoteServer : public std::enable_shared_from_this { } { std::scoped_lock const lock{_regMtx}; - _models.erase(mid); - _owners.erase(mid); - // Keep the connection scope's membership set in sync: an - // explicit wire deregister removes the id from its scope - // too, so a later closeConnection never double-erases it. + releaseInstanceLocked(mid); + // Keep the connection scope's membership in sync: an + // explicit wire deregister drops one of this connection's + // references, so a later closeConnection never + // double-releases it. if (auto connIter = _modelConnection.find(mid); connIter != _modelConnection.end()) { if (auto scopeIter = _connectionScopes.find(connIter->second); scopeIter != _connectionScopes.end()) { - scopeIter->second.erase(mid); + auto refIter = scopeIter->second.find(mid); + if (refIter != scopeIter->second.end()) { + refIter->second -= 1; + if (refIter->second == 0) { + scopeIter->second.erase(refIter); + } + } + } + if (!_models.contains(mid)) { + _modelConnection.erase(connIter); } - _modelConnection.erase(connIter); } } reply(::morph::wire::encode(::morph::wire::makeOk(env.callId))); @@ -1024,13 +1274,26 @@ class RemoteServer : public std::enable_shared_from_this { // and the scoped handle(msg, reply, cid) overload). Guarded by _regMtx — // the same lock as _models/_owners — so scope membership can never desync // from instance existence. - std::unordered_map> + // Value is a *count* per instance, not a set: one connection may attach the + // same shared instance from two handlers, and closing the connection must + // release both references or the instance leaks. A private instance always + // has a count of exactly 1. + std::unordered_map> _connectionScopes; // Owning connection recorded per scoped instance; absent means unscoped // (registered via the two-argument handle()/handleInline()). std::unordered_map<::morph::exec::detail::ModelId, ConnectionId, ::morph::exec::detail::ModelIdHash> _modelConnection; + // Shared-instance directory: (typeId, primary) -> ModelId, its reverse, and + // the cross-connection attach count. Guarded by _regMtx alongside + // _models/_owners so directory membership can never desync from instance + // existence. Only instances registered with `shared` set and a non-empty + // primary appear; a private instance has no entry in any of the three. + using DirectoryKey = std::pair; + std::unordered_map _directory; + std::unordered_map<::morph::exec::detail::ModelId, DirectoryKey, ::morph::exec::detail::ModelIdHash> _sharedKeyOf; + std::unordered_map<::morph::exec::detail::ModelId, std::size_t, ::morph::exec::detail::ModelIdHash> _attachCount; std::atomic _nextId{0}; std::atomic _nextConnectionId{0}; std::atomic _minVersion{::morph::wire::kProtocolVersion}; @@ -1115,6 +1378,89 @@ class SimulatedRemoteBackend : public detail::IBackend { throw std::runtime_error("register failed: " + reply.message); } + /// @brief Registers or attaches to the server's shared instance for @p identity. + /// + /// Sends a `shared` register, so the server returns the live instance for + /// `(typeId, primary)` when one exists rather than creating a second. An + /// empty primary degrades to the private path. + /// @param typeId String type-id sent in the `register` message. + /// @param factory Ignored — the server constructs via its own registry. + /// @param identity Entity key for the action log plus the directory primary key. + /// @return `ModelId` of the shared (or newly created) instance. + /// @throws std::runtime_error if the server replies with an error. + ::morph::exec::detail::ModelId registerModelShared( + const std::string& typeId, std::function()> factory, + detail::InstanceIdentity identity) override { + if (identity.primary.empty()) { + return registerModelWithContext(typeId, std::move(factory), identity.contextKey); + } + auto reply = + ::morph::wire::decode(_server.handleInline(::morph::wire::encode(::morph::wire::makeRegisterShared( + typeId, std::string{identity.primary}, std::string{identity.contextKey})))); + if (reply.kind == "ok") { + return ::morph::exec::detail::ModelId{reply.modelId}; + } + throw std::runtime_error("register failed: " + reply.message); + } + + /// @brief Re-points from @p current to the server's shared instance for @p identity. + /// + /// One `attach` request rather than a deregister/register pair, so the + /// re-pointing client cannot lose its slot to `LimitPolicy::maxLiveModels` + /// between releasing the old instance and acquiring the new one. + /// @param typeId String type-id sent in the `attach` message. + /// @param factory Ignored — the server constructs via its own registry. + /// @param identity Entity key for the action log plus the directory primary key. + /// @param current Instance currently held, or `ModelId{0}` if none. + /// @return `ModelId` of the instance now attached to. + /// @throws std::runtime_error if the server replies with an error. + ::morph::exec::detail::ModelId attachModel( + const std::string& typeId, std::function()> factory, + detail::InstanceIdentity identity, ::morph::exec::detail::ModelId current) override { + if (identity.primary.empty()) { + if (current.v != 0U) { + deregisterModel(current); + } + return registerModelWithContext(typeId, std::move(factory), identity.contextKey); + } + auto reply = ::morph::wire::decode(_server.handleInline( + ::morph::wire::encode(::morph::wire::makeAttach(typeId, std::string{identity.primary}, current.v)))); + if (reply.kind == "ok") { + return ::morph::exec::detail::ModelId{reply.modelId}; + } + throw std::runtime_error("attach failed: " + reply.message); + } + + /// @brief Files a live server-side instance under @p primary. + /// @param mid Live instance to promote. + /// @param typeId Model type id. + /// @param primary Canonical string encoding of the key to file it under. + void assignPrimary(::morph::exec::detail::ModelId mid, const std::string& typeId, + std::string_view primary) override { + if (primary.empty() || mid.v == 0U) { + return; + } + (void)_server.handleInline( + ::morph::wire::encode(::morph::wire::makeAssign(typeId, std::string{primary}, mid.v))); + } + + /// @brief Asks the server for the live shared primary keys of @p typeId. + /// @param typeId String type-id to enumerate. + /// @return Canonical key strings of the live shared instances. + /// @throws std::runtime_error if the server replies with an error. + std::vector listInstances(const std::string& typeId) override { + auto reply = + ::morph::wire::decode(_server.handleInline(::morph::wire::encode(::morph::wire::makeInstances(typeId)))); + if (reply.kind != "ok") { + throw std::runtime_error("instances failed: " + reply.message); + } + std::vector keys; + if (auto errCode = glz::read_json(keys, reply.body)) { + throw std::runtime_error("instances decode failed: " + glz::format_error(errCode, reply.body)); + } + return keys; + } + /// @brief Deregisters the model on the server. Processed inline; safe from any thread. /// @param mid Id of the model to deregister. void deregisterModel(::morph::exec::detail::ModelId mid) override { diff --git a/include/morph/core/wire.hpp b/include/morph/core/wire.hpp index de8f8725..43cb6180 100644 --- a/include/morph/core/wire.hpp +++ b/include/morph/core/wire.hpp @@ -46,6 +46,14 @@ inline constexpr std::uint32_t kProtocolVersion = 1; /// - `"register"` — client requests model creation. Uses `typeId`, and /// optionally `contextKey` (the new instance's stable /// identity, e.g. an account id — see `RemoteServer::setLogProvider`). +/// With `shared` set, additionally uses `primary` and becomes +/// a register-or-attach against the shared directory. +/// - `"attach"` — client re-points at a different `primary` of `typeId`, +/// releasing `modelId` if non-zero. Replies `ok` with the +/// target instance's id in `modelId`. +/// - `"assign"` — client files live `modelId` under `primary` of `typeId`. +/// - `"instances"` — client asks for the live shared primary keys of `typeId`. +/// Replies `ok` with a JSON array of key strings in `body`. /// - `"deregister"` — client destroys an instance. Uses `modelId`. /// - `"execute"` — client dispatches an action. Uses `callId`, `modelId`, /// `modelType`, `actionType`, `body`, and optionally `session`. @@ -69,6 +77,29 @@ struct Envelope { /// Ignored on every kind other than `register`. std::string contextKey; + /// @brief Primary key of the instance being registered or attached to. + /// + /// Carried on `register` (when `shared` is set) and on `attach`, always as + /// the key's canonical string encoding (`morph::model::keyToString`) + /// whatever its C++ type, so the server's directory needs one map type + /// rather than one per key type. Empty means "no primary" — the instance is + /// anonymous and cannot be shared. Ignored on every other kind. + /// + /// Distinct from `contextKey`, which continues to mean only "entity key for + /// the action log". A keyed model will normally set both to the same value, + /// but conflating the fields would change behaviour for callers already + /// setting `contextKey` for journal purposes. + std::string primary; + + /// @brief Whether a `register` should join the shared instance directory. + /// + /// `true` makes the request a *register-or-attach*: the server returns the + /// existing instance for `(typeId, primary)` if one is live, otherwise + /// creates it and enters it in the directory. `false` (the default, and the + /// value every pre-existing client sends) is today's behaviour exactly — a + /// private instance, invisible to the directory. + bool shared = false; + /// @brief Existing model instance id for `deregister`, `execute`, `ok(register)`. uint64_t modelId = 0; @@ -124,6 +155,79 @@ inline Envelope makeRegister(std::string typeId, std::string contextKey = {}) { return env; } +/// @brief Builds a shared (register-or-attach) `register` envelope. +/// +/// The server returns the live instance for `(typeId, primary)` if one exists, +/// otherwise creates it and enters it in the shared directory. A shared instance +/// is recorded with no owner principal, so `IAuthorizer::authorizeInstance`'s +/// documented `ownerPrincipal == ctx.principal` policy does not lock the second +/// client out of an instance the first created — see +/// docs/planned/shared_model_instances.md. +/// +/// @param typeId Model type id to register or attach to. +/// @param primary Canonical string encoding of the instance's primary key. +/// @param contextKey Optional entity key for the action log (default: none). +inline Envelope makeRegisterShared(std::string typeId, std::string primary, std::string contextKey = {}) { + Envelope env; + env.kind = "register"; + env.typeId = std::move(typeId); + env.primary = std::move(primary); + env.contextKey = std::move(contextKey); + env.shared = true; + return env; +} + +/// @brief Builds an `attach` envelope — re-points a client at a different primary. +/// +/// Semantically a `deregister` + shared `register` pair, made a single request +/// so a re-pointing handler cannot lose its slot to `LimitPolicy::maxLiveModels` +/// between releasing the old instance and acquiring the new one. +/// +/// @param typeId Model type id. +/// @param primary Canonical string encoding of the primary key to attach to. +/// @param modelId Instance the client is currently attached to; `0` if none. +inline Envelope makeAttach(std::string typeId, std::string primary, uint64_t modelId = 0) { + Envelope env; + env.kind = "attach"; + env.typeId = std::move(typeId); + env.primary = std::move(primary); + env.modelId = modelId; + env.shared = true; + return env; +} + +/// @brief Builds an `assign` envelope — files a live instance under a primary key. +/// +/// The promotion half of keyed instances: an action that creates its own entity +/// runs on a not-yet-keyed instance, and only the reply carries the generated +/// key. Assigning promotes that same instance in place, so nothing the create +/// did is stranded on a throwaway. +/// @param typeId Model type id. +/// @param primary Canonical string encoding of the key to file the instance under. +/// @param modelId Live instance to promote. +inline Envelope makeAssign(std::string typeId, std::string primary, uint64_t modelId) { + Envelope env; + env.kind = "assign"; + env.typeId = std::move(typeId); + env.primary = std::move(primary); + env.modelId = modelId; + env.shared = true; + return env; +} + +/// @brief Builds an `instances` envelope — asks for the live shared keys of a type. +/// +/// The reply's `body` is a JSON array of canonical key strings. Only instances +/// created through a shared `register`/`attach` are listed; a private instance +/// is invisible to the directory by construction. +/// @param typeId Model type id to enumerate. +inline Envelope makeInstances(std::string typeId) { + Envelope env; + env.kind = "instances"; + env.typeId = std::move(typeId); + return env; +} + /// @brief Builds a `deregister` envelope. inline Envelope makeDeregister(uint64_t modelId) { Envelope env; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f8a2b030..6ccaa781 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -45,6 +45,7 @@ add_executable(morph_tests test_coverage_extra.cpp test_coverage_push95.cpp test_server_limits.cpp + test_shared_instances.cpp test_wire_hardening.cpp test_protocol_version.cpp test_limit_policy.cpp diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp new file mode 100644 index 00000000..b080b27a --- /dev/null +++ b/tests/test_shared_instances.cpp @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Tests for keyed, shareable model instances (F2). +// +// A model declares a `PrimaryKey` alias; actions declare which field carries it +// (`BRIDGE_KEY_FROM`) or that their result establishes it +// (`BRIDGE_KEY_FROM_RESULT`); `BridgeHandler` joins a +// server-side directory keyed on `(typeId, primary)`. These tests pin the four +// properties the design turns on: two shared handlers naming one key reach one +// instance, a plain handler never does, the instance survives until the last +// attachment goes away, and all of it behaves identically local and remote. +// +// See docs/planned/shared_model_instances.md. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +namespace { + +// Model/action types need external linkage for glaze reflection, so they live +// at namespace scope below rather than inside the test cases. + +} // namespace + +/// A counter whose value lives *in the instance* — the whole point of keying. +/// A stateless model would make every one of these tests vacuous. +struct ShiCounterState { + std::int64_t value = 0; +}; + +struct ShiAddTo { + std::int64_t id = 0; + std::int64_t amount = 0; +}; + +struct ShiRead { + std::int64_t id = 0; +}; + +/// Keyless: runs against whatever instance the handler already holds. +struct ShiPeek { + int unused = 0; +}; + +struct ShiCounterModel { + using PrimaryKey = std::int64_t; + + std::int64_t value = 0; + + ShiCounterState execute(const ShiAddTo& act) { + value += act.amount; + return {.value = value}; + } + ShiCounterState execute(const ShiRead& /*act*/) const { return {.value = value}; } + ShiCounterState execute(const ShiPeek& /*act*/) const { return {.value = value}; } +}; + +BRIDGE_REGISTER_MODEL(ShiCounterModel, "SHI_CounterModel") +BRIDGE_REGISTER_ACTION(ShiCounterModel, ShiAddTo, "SHI_AddTo") +BRIDGE_REGISTER_ACTION(ShiCounterModel, ShiRead, "SHI_Read") +BRIDGE_REGISTER_ACTION(ShiCounterModel, ShiPeek, "SHI_Peek") + +BRIDGE_KEY_FROM(ShiAddTo, &ShiAddTo::id); +BRIDGE_KEY_FROM(ShiRead, &ShiRead::id); + +namespace { + +using morph::bridge::AllowShared; +using morph::bridge::Bridge; +using morph::bridge::BridgeHandler; + +/// Drives a `Completion` to resolution and returns the value. +/// +/// Polls rather than assuming synchronous resolution: `LocalBackend` resolves on +/// the caller's thread here, but `SimulatedRemoteBackend` posts to a worker pool, +/// and the same test bodies run against both. +template +T settle(morph::async::Completion comp) { + auto out = std::make_shared(); + auto done = std::make_shared>(false); + auto failed = std::make_shared>(false); + std::move(comp) + .then([out, done](T value) { + *out = std::move(value); + done->store(true); + }) + .onError([failed, done](const std::exception_ptr&) { + failed->store(true); + done->store(true); + }); + REQUIRE(morph::testing::waitUntil([&] { return done->load(); })); + REQUIRE_FALSE(failed->load()); + return *out; +} + +std::unique_ptr makeLocal(morph::exec::IExecutor& pool) { + return std::make_unique(pool); +} + +} // namespace + +TEST_CASE("two AllowShared handlers naming one key reach one instance", "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + + BridgeHandler first{bridge, &exec}; + BridgeHandler second{bridge, &exec}; + + // A keyed action attaches the handler on the way through. + REQUIRE(settle(first.execute(ShiAddTo{.id = 42, .amount = 10})).value == 10); + // The second handler names the same key, so it lands on the same counter + // and observes the first handler's work. + REQUIRE(settle(second.execute(ShiAddTo{.id = 42, .amount = 5})).value == 15); + REQUIRE(settle(first.execute(ShiRead{.id = 42})).value == 15); + + REQUIRE(first.primary().value() == 42); + REQUIRE(second.primary().value() == 42); +} + +TEST_CASE("a plain handler never joins the directory", "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + + BridgeHandler shared{bridge, &exec}; + BridgeHandler priv{bridge, &exec}; + + REQUIRE(settle(shared.execute(ShiAddTo{.id = 7, .amount = 100})).value == 100); + // Same key, but this handler opted out — it gets its own instance, starting + // from zero, exactly as every pre-existing call site does. + REQUIRE(settle(priv.execute(ShiAddTo{.id = 7, .amount = 1})).value == 1); + // …and the shared instance is untouched by it. + REQUIRE(settle(shared.execute(ShiRead{.id = 7})).value == 100); +} + +TEST_CASE("different keys are different instances", "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + + BridgeHandler handler{bridge, &exec}; + REQUIRE(settle(handler.execute(ShiAddTo{.id = 1, .amount = 3})).value == 3); + // A different key is a different counter, not a re-labelled one. + REQUIRE(settle(handler.execute(ShiAddTo{.id = 2, .amount = 8})).value == 8); + + // Re-pointing away released the *last* attachment to instance 1, so it was + // destroyed: lifetime is refcounted, not cached. Coming back therefore finds + // a fresh counter. Keeping it alive is what a second handler is for — see + // the re-pointing test below. + REQUIRE(settle(handler.execute(ShiRead{.id = 1})).value == 0); +} + +TEST_CASE("a keyed action re-points the handler rather than re-keying the instance", "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + + BridgeHandler mover{bridge, &exec}; + BridgeHandler pinned{bridge, &exec}; + + settle(mover.execute(ShiAddTo{.id = 100, .amount = 50})); + settle(pinned.execute(ShiRead{.id = 100})); // pin instance 100 alive + + // The primary is deliberately not write-once: naming another key moves the + // *handler*, leaving instance 100 and its state intact for `pinned`. + settle(mover.execute(ShiAddTo{.id = 200, .amount = 1})); + REQUIRE(mover.primary().value() == 200); + REQUIRE(settle(pinned.execute(ShiPeek{})).value == 50); +} + +TEST_CASE("an instance outlives any single handler and dies with the last one", "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + + BridgeHandler survivor{bridge, &exec}; + settle(survivor.execute(ShiAddTo{.id = 9, .amount = 4})); + { + BridgeHandler transient{bridge, &exec}; + settle(transient.execute(ShiAddTo{.id = 9, .amount = 6})); + REQUIRE(settle(transient.execute(ShiRead{.id = 9})).value == 10); + } // transient releases one attachment — the instance must survive + + REQUIRE(settle(survivor.execute(ShiPeek{})).value == 10); + + { + // Once the last handler goes, the instance and its directory entry go + // with it, so a later attach starts from a fresh counter. + BridgeHandler lastOne{bridge, &exec}; + settle(lastOne.execute(ShiRead{.id = 9})); + } +} + +TEST_CASE("releasing every handler drops the instance and its directory entry", "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + + { + BridgeHandler only{bridge, &exec}; + settle(only.execute(ShiAddTo{.id = 500, .amount = 77})); + REQUIRE(settle(only.instances()).size() == 1); + } + + BridgeHandler fresh{bridge, &exec}; + REQUIRE(settle(fresh.instances()).empty()); + // A fresh attach to the same key starts from zero — the old instance is gone. + REQUIRE(settle(fresh.execute(ShiRead{.id = 500})).value == 0); +} + +TEST_CASE("instances() lists the live shared keys", "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + + BridgeHandler alpha{bridge, &exec}; + BridgeHandler beta{bridge, &exec}; + BridgeHandler hidden{bridge, &exec}; + + settle(alpha.execute(ShiAddTo{.id = 11, .amount = 1})); + settle(beta.execute(ShiAddTo{.id = 22, .amount = 1})); + settle(hidden.execute(ShiAddTo{.id = 33, .amount = 1})); // private: not listed + + auto keys = settle(alpha.instances()); + std::ranges::sort(keys); + REQUIRE(keys == std::vector{11, 22}); +} + +TEST_CASE("explicit attach binds without executing an action", "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + + BridgeHandler handler{bridge, &exec}; + REQUIRE_FALSE(handler.primary().has_value()); + + handler.attach(64); + REQUIRE(handler.primary().value() == 64); + // A keyless action now has an instance to run against. + REQUIRE(settle(handler.execute(ShiPeek{})).value == 0); +} + +TEST_CASE("an unattached shared handler fails a keyless action fast", "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + + BridgeHandler handler{bridge, &exec}; + + // No primary yet: there is no instance to run against, and quietly inventing + // a private one would defeat the sharing the caller asked for. + bool failed = false; + handler.execute(ShiPeek{}).onError([&](const std::exception_ptr&) { failed = true; }); + REQUIRE(failed); +} + +TEST_CASE("sharing works identically across a remote backend", "[shared-instances]") { + morph::testing::InlineExecutor exec; + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool); + + // Two independent Bridges over one server stand in for two clients: the + // directory lives server-side precisely so they meet on one instance. + Bridge clientA{std::make_unique(*server)}; + Bridge clientB{std::make_unique(*server)}; + + BridgeHandler fromA{clientA, &exec}; + BridgeHandler fromB{clientB, &exec}; + + REQUIRE(settle(fromA.execute(ShiAddTo{.id = 314, .amount = 20})).value == 20); + // The second *client* — not merely the second handler — sees the first's work. + REQUIRE(settle(fromB.execute(ShiAddTo{.id = 314, .amount = 2})).value == 22); + REQUIRE(settle(fromB.instances()) == std::vector{314}); +} + +TEST_CASE("a remote plain handler still gets its own instance", "[shared-instances]") { + morph::testing::InlineExecutor exec; + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool); + Bridge bridge{std::make_unique(*server)}; + + BridgeHandler shared{bridge, &exec}; + BridgeHandler priv{bridge, &exec}; + + settle(shared.execute(ShiAddTo{.id = 8, .amount = 30})); + REQUIRE(settle(priv.execute(ShiAddTo{.id = 8, .amount = 1})).value == 1); + REQUIRE(settle(shared.execute(ShiRead{.id = 8})).value == 30); +} + +TEST_CASE("primary keys round-trip through their canonical encoding", "[shared-instances]") { + REQUIRE(morph::model::keyToString(-17) == "-17"); + REQUIRE(morph::model::keyFromString("-17") == -17); + REQUIRE(morph::model::keyToString("abc") == "abc"); + REQUIRE(morph::model::keyFromString("abc") == "abc"); + // A malformed key is a protocol error, never a value to clamp: decoding it + // to 0 would silently route the caller to the wrong instance. + REQUIRE_THROWS(morph::model::keyFromString("12x")); + REQUIRE_THROWS(morph::model::keyFromString("")); +} + +TEST_CASE("keyed models and keyed actions are detected structurally", "[shared-instances]") { + STATIC_REQUIRE(morph::model::KeyedModel); + STATIC_REQUIRE_FALSE(morph::model::KeyedModel); + STATIC_REQUIRE(morph::model::detail::PayloadKeyed); + STATIC_REQUIRE_FALSE(morph::model::detail::PayloadKeyed); + STATIC_REQUIRE_FALSE(morph::model::ActionKeyTraits::hasKey); +} From dbe1ce184ee86a7b7b6be62ba757cb0a696b970c Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 27 Jul 2026 23:19:36 +0200 Subject: [PATCH 03/42] feat(transport): carry keyed instance sharing over the real transports SocketBackend and QtWebSocketBackend implement registerModelShared, attachModel, assignPrimary and listInstances, so cross-client sharing works over the raw-socket and Qt WebSocket transports and not only through SimulatedRemoteBackend. Both keep the same synchronous control-call discipline registerModel already had, and both degrade to the private path on an empty primary. Co-Authored-By: Claude Opus 5 (1M context) --- include/morph/core/bridge.hpp | 7 +- include/morph/net/socket_backend.hpp | 92 +++++++++++++++++++++++ include/morph/qt/qt_websocket_backend.hpp | 36 +++++++++ src/qt/qt_websocket_backend.cpp | 63 ++++++++++++++++ 4 files changed, 197 insertions(+), 1 deletion(-) diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index 830045e7..6e6c2a83 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -9,9 +9,9 @@ #include #include #include -#include #include #include +#include #include #include #include @@ -507,6 +507,11 @@ class Bridge { /// @param binding Binding returned by `registerHandler()`. /// @param action Action to execute (moved in). /// @param cbExec Executor on which the `Completion` callbacks are posted. + /// @param onResult Optional observer run on the typed result *before* it is + /// moved into the returned `Completion` and before the + /// caller's own `.then`. Used to adopt a result-sourced + /// primary key so the binding is already promoted by the time + /// user code sees the result; empty for every other call. /// @return Completion that resolves with the typed result or an exception /// (including `ValidationError` on `LocalBackend` when the action /// fails its validator). diff --git a/include/morph/net/socket_backend.hpp b/include/morph/net/socket_backend.hpp index d2108cc8..aed70393 100644 --- a/include/morph/net/socket_backend.hpp +++ b/include/morph/net/socket_backend.hpp @@ -145,6 +145,79 @@ class SocketBackend : public ::morph::backend::detail::IBackend { throw std::runtime_error("register failed: " + reply.message); } + /// @brief Sends a shared (register-or-attach) `register` and blocks for the reply. + /// + /// An empty primary degrades to the private path. Same synchronous-call + /// constraint as `registerModel`. + /// @param typeId String type-id of the model. + /// @param factory Ignored — the server constructs via its own registry. + /// @param identity Entity key for the action log plus the directory primary key. + /// @return `ModelId` of the shared (or newly created) instance. + /// @throws std::runtime_error if the server replies with an error or the socket is down. + ::morph::exec::detail::ModelId registerModelShared( + const std::string& typeId, std::function()> factory, + ::morph::backend::detail::InstanceIdentity identity) override { + if (identity.primary.empty()) { + return registerModelWithContext(typeId, std::move(factory), identity.contextKey); + } + return sendControlForId(::morph::wire::makeRegisterShared(typeId, std::string{identity.primary}, + std::string{identity.contextKey}), + "register"); + } + + /// @brief Sends an `attach` and blocks for the reply, re-pointing from @p current. + /// @param typeId String type-id of the model. + /// @param factory Ignored — the server constructs via its own registry. + /// @param identity Entity key for the action log plus the directory primary key. + /// @param current Instance currently held, or `ModelId{0}` if none. + /// @return `ModelId` of the instance now attached to. + /// @throws std::runtime_error if the server replies with an error or the socket is down. + ::morph::exec::detail::ModelId attachModel( + const std::string& typeId, std::function()> factory, + ::morph::backend::detail::InstanceIdentity identity, ::morph::exec::detail::ModelId current) override { + if (identity.primary.empty()) { + if (current.v != 0U) { + deregisterModel(current); + } + return registerModelWithContext(typeId, std::move(factory), identity.contextKey); + } + return sendControlForId(::morph::wire::makeAttach(typeId, std::string{identity.primary}, current.v), "attach"); + } + + /// @brief Files a live server-side instance under @p primary. + /// @param mid Live instance to promote. + /// @param typeId Model type id. + /// @param primary Canonical string encoding of the key to file it under. + void assignPrimary(::morph::exec::detail::ModelId mid, const std::string& typeId, + std::string_view primary) override { + if (primary.empty() || mid.v == 0U) { + return; + } + (void)sendControlForId(::morph::wire::makeAssign(typeId, std::string{primary}, mid.v), "assign"); + } + + /// @brief Asks the server for the live shared primary keys of @p typeId. + /// @param typeId String type-id to enumerate. + /// @return Canonical key strings of the live shared instances. + /// @throws std::runtime_error if the server replies with an error or the socket is down. + std::vector listInstances(const std::string& typeId) override { + std::string replyJson; + try { + replyJson = sendSync(::morph::wire::encode(::morph::wire::makeInstances(typeId))); + } catch (const std::exception& exc) { + throw std::runtime_error(std::string{"instances failed: "} + exc.what()); + } + auto reply = ::morph::wire::decode(replyJson); + if (reply.kind != "ok") { + throw std::runtime_error("instances failed: " + reply.message); + } + std::vector keys; + if (auto errCode = glz::read_json(keys, reply.body)) { + throw std::runtime_error("instances decode failed: " + glz::format_error(errCode, reply.body)); + } + return keys; + } + /// @brief Sends a `deregister` message fire-and-forget (does not wait for a reply). /// @param mid Id of the model to remove on the server. void deregisterModel(::morph::exec::detail::ModelId mid) override { @@ -160,6 +233,25 @@ class SocketBackend : public ::morph::backend::detail::IBackend { } } + /// @brief Sends one synchronous control envelope and returns the replied `modelId`. + /// @param env Envelope to send. + /// @param what Verb name used in the error message. + /// @return `ModelId` carried by the `ok` reply. + /// @throws std::runtime_error if the server errors or the socket is down. + ::morph::exec::detail::ModelId sendControlForId(const ::morph::wire::Envelope& env, std::string_view what) { + std::string replyJson; + try { + replyJson = sendSync(::morph::wire::encode(env)); + } catch (const std::exception& exc) { + throw std::runtime_error(std::string{what} + " failed: " + exc.what()); + } + auto reply = ::morph::wire::decode(replyJson); + if (reply.kind == "ok") { + return ::morph::exec::detail::ModelId{reply.modelId}; + } + throw std::runtime_error(std::string{what} + " failed: " + reply.message); + } + /// @brief Sends an `execute` message and returns a `Completion` resolved on reply. /// @param mid Target model id on the server. /// @param call Bundled action; `serializeAction` and `deserializeResult` are used. diff --git a/include/morph/qt/qt_websocket_backend.hpp b/include/morph/qt/qt_websocket_backend.hpp index d7d12080..3ce9a95e 100644 --- a/include/morph/qt/qt_websocket_backend.hpp +++ b/include/morph/qt/qt_websocket_backend.hpp @@ -107,6 +107,42 @@ class QtWebSocketBackend : public ::morph::backend::detail::IBackend { const std::string& typeId, std::function()> factory) override; + /// @brief Sends a shared (register-or-attach) `register` and blocks for the reply. + /// + /// An empty primary degrades to the private path. + /// @param typeId String type-id of the model. + /// @param factory Ignored — model construction is delegated to the server. + /// @param identity Entity key for the action log plus the directory primary key. + /// @return `ModelId` of the shared (or newly created) instance. + /// @throws std::runtime_error if the server errors or the socket is not connected. + ::morph::exec::detail::ModelId registerModelShared( + const std::string& typeId, std::function()> factory, + ::morph::backend::detail::InstanceIdentity identity) override; + + /// @brief Sends an `attach` and blocks for the reply, re-pointing from @p current. + /// @param typeId String type-id of the model. + /// @param factory Ignored — model construction is delegated to the server. + /// @param identity Entity key for the action log plus the directory primary key. + /// @param current Instance currently held, or `ModelId{0}` if none. + /// @return `ModelId` of the instance now attached to. + /// @throws std::runtime_error if the server errors or the socket is not connected. + ::morph::exec::detail::ModelId attachModel( + const std::string& typeId, std::function()> factory, + ::morph::backend::detail::InstanceIdentity identity, ::morph::exec::detail::ModelId current) override; + + /// @brief Files a live server-side instance under @p primary. + /// @param mid Live instance to promote. + /// @param typeId Model type id. + /// @param primary Canonical string encoding of the key to file it under. + void assignPrimary(::morph::exec::detail::ModelId mid, const std::string& typeId, + std::string_view primary) override; + + /// @brief Asks the server for the live shared primary keys of @p typeId. + /// @param typeId String type-id to enumerate. + /// @return Canonical key strings of the live shared instances. + /// @throws std::runtime_error if the server errors or the socket is not connected. + std::vector listInstances(const std::string& typeId) override; + /// @brief Sends a `deregister` message fire-and-forget (does not wait for a reply). /// /// No acknowledgement is awaited, which avoids a nested `QEventLoop` during diff --git a/src/qt/qt_websocket_backend.cpp b/src/qt/qt_websocket_backend.cpp index 4f6af27f..a258c34e 100644 --- a/src/qt/qt_websocket_backend.cpp +++ b/src/qt/qt_websocket_backend.cpp @@ -142,6 +142,69 @@ ::morph::wire::ProtocolNegotiationResult QtWebSocketBackend::negotiateProtocolVe return ::morph::wire::interpretHelloReply(::morph::wire::decode(replyJson)); } +namespace { + +/// @brief Decodes a synchronous control reply and returns its `modelId`. +/// @param replyJson Raw reply text. +/// @param what Verb name used in the error message. +/// @return The replied `ModelId`. +/// @throws std::runtime_error if the reply is an `err`. +::morph::exec::detail::ModelId modelIdFromReply(const std::string& replyJson, std::string_view what) { + auto reply = ::morph::wire::decode(replyJson); + if (reply.kind == "ok") { + return ::morph::exec::detail::ModelId{reply.modelId}; + } + throw std::runtime_error(std::string{what} + " failed: " + reply.message); +} + +} // namespace + +::morph::exec::detail::ModelId QtWebSocketBackend::registerModelShared( + const std::string& typeId, std::function()> factory, + ::morph::backend::detail::InstanceIdentity identity) { + if (identity.primary.empty()) { + return registerModelWithContext(typeId, std::move(factory), identity.contextKey); + } + return modelIdFromReply(sendSync(::morph::wire::encode(::morph::wire::makeRegisterShared( + typeId, std::string{identity.primary}, std::string{identity.contextKey}))), + "register"); +} + +::morph::exec::detail::ModelId QtWebSocketBackend::attachModel( + const std::string& typeId, std::function()> factory, + ::morph::backend::detail::InstanceIdentity identity, ::morph::exec::detail::ModelId current) { + if (identity.primary.empty()) { + if (current.v != 0U) { + deregisterModel(current); + } + return registerModelWithContext(typeId, std::move(factory), identity.contextKey); + } + return modelIdFromReply( + sendSync(::morph::wire::encode(::morph::wire::makeAttach(typeId, std::string{identity.primary}, current.v))), + "attach"); +} + +void QtWebSocketBackend::assignPrimary(::morph::exec::detail::ModelId mid, const std::string& typeId, + std::string_view primary) { + if (primary.empty() || mid.v == 0U) { + return; + } + (void)modelIdFromReply( + sendSync(::morph::wire::encode(::morph::wire::makeAssign(typeId, std::string{primary}, mid.v))), "assign"); +} + +std::vector QtWebSocketBackend::listInstances(const std::string& typeId) { + auto reply = ::morph::wire::decode(sendSync(::morph::wire::encode(::morph::wire::makeInstances(typeId)))); + if (reply.kind != "ok") { + throw std::runtime_error("instances failed: " + reply.message); + } + std::vector keys; + if (auto errCode = glz::read_json(keys, reply.body)) { + throw std::runtime_error("instances decode failed: " + glz::format_error(errCode, reply.body)); + } + return keys; +} + void QtWebSocketBackend::deregisterModel(::morph::exec::detail::ModelId mid) { // Fire-and-forget — avoids a nested QEventLoop during destructor which can // trigger Qt asserts. The server does no connection-scoped cleanup, so an From 36796eeecd0a00f74ef593ad8ef917ff6ced31ec Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 27 Jul 2026 23:33:02 +0200 Subject: [PATCH 04/42] feat(bank): reshape the example onto stateful, keyed models Every bank model was stateless -- its only member was the DataMapper inherited from WithMapper -- so the per-instance strand morph advertises as its core service protected nothing, and the example demonstrated the bridge while making the model layer look like a thin RPC shim. AccountModel now holds one account in memory, keyed by account id, hydrated on first use and written through on mutation. CustomerModel takes the per-owner half (ListAccounts/OpenAccount), which was never account-scoped -- the `owner` field on those DTOs was the symptom of one model doing two jobs. Cross-model writes (transfer, bill payment, loan disbursement) settle inside a SqlTransaction owned by another model, so they land behind a cached row's back. bank/db/row_versions.hpp is the smallest honest fix: writers bump a counter, cached readers re-hydrate on a stale version. Documented as the example's sharp edge rather than arranged away. WASM shadow models and the five GUI controllers move with it; the desktop GUI, CLI, and all 21 bank tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- examples/bank/CMakeLists.txt | 2 + examples/bank/README.md | 36 ++++- .../gui/controllers/AccountController.hpp | 4 +- .../bank/gui/controllers/CardController.hpp | 4 +- .../bank/gui/controllers/LoanController.hpp | 4 +- .../bank/gui/controllers/PayeeController.hpp | 4 +- .../gui/controllers/TransactionController.hpp | 4 +- examples/bank/gui_wasm/CMakeLists.txt | 1 + .../include/bank/models/account_model.hpp | 30 ++-- .../include/bank/models/customer_model.hpp | 36 +++++ .../gui_wasm/include/bank/wasm/store_ops.hpp | 4 + .../src/models/account_model_wasm.cpp | 84 +++-------- .../src/models/customer_model_wasm.cpp | 87 +++++++++++ .../bank/include/bank/db/account_mapping.hpp | 36 +++++ examples/bank/include/bank/db/ledger_ops.hpp | 3 + .../bank/include/bank/db/row_versions.hpp | 74 ++++++++++ .../include/bank/models/account_model.hpp | 58 +++++--- .../include/bank/models/customer_model.hpp | 51 +++++++ examples/bank/src/cli/main.cpp | 4 +- examples/bank/src/models/account_model.cpp | 111 ++++---------- examples/bank/src/models/customer_model.cpp | 81 +++++++++++ examples/bank/tests/test_account.cpp | 19 +-- examples/bank/tests/test_budget.cpp | 4 +- examples/bank/tests/test_card.cpp | 4 +- examples/bank/tests/test_loan.cpp | 4 +- examples/bank/tests/test_offline.cpp | 4 +- examples/bank/tests/test_payment.cpp | 4 +- examples/bank/tests/test_relations.cpp | 4 +- examples/bank/tests/test_remote.cpp | 6 +- examples/bank/tests/test_stateful_account.cpp | 135 ++++++++++++++++++ examples/bank/tests/test_statement.cpp | 4 +- examples/bank/tests/test_transaction.cpp | 13 +- 32 files changed, 703 insertions(+), 216 deletions(-) create mode 100644 examples/bank/gui_wasm/include/bank/models/customer_model.hpp create mode 100644 examples/bank/gui_wasm/src/models/customer_model_wasm.cpp create mode 100644 examples/bank/include/bank/db/account_mapping.hpp create mode 100644 examples/bank/include/bank/db/row_versions.hpp create mode 100644 examples/bank/include/bank/models/customer_model.hpp create mode 100644 examples/bank/src/models/customer_model.cpp create mode 100644 examples/bank/tests/test_stateful_account.cpp diff --git a/examples/bank/CMakeLists.txt b/examples/bank/CMakeLists.txt index 698629c1..112b315b 100644 --- a/examples/bank/CMakeLists.txt +++ b/examples/bank/CMakeLists.txt @@ -50,6 +50,7 @@ add_library(bank_lib STATIC src/core/money.cpp src/db/schema.cpp src/models/account_model.cpp + src/models/customer_model.cpp src/models/auth_model.cpp src/models/transaction_model.cpp src/models/payee_model.cpp @@ -97,6 +98,7 @@ if(MORPH_BUILD_TESTS) tests/test_remote.cpp tests/test_offline.cpp tests/test_relations.cpp + tests/test_stateful_account.cpp ) target_include_directories(bank_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tests) target_link_libraries(bank_tests PRIVATE bank_lib Catch2::Catch2WithMain) diff --git a/examples/bank/README.md b/examples/bank/README.md index adb33d21..220d2d66 100644 --- a/examples/bank/README.md +++ b/examples/bank/README.md @@ -37,13 +37,43 @@ GUI / CLI ──actions/results (plain DTOs)──▶ morph Bridge ──▶ Mod `entities.hpp`), the shared `WithMapper` mixin (one lazily-opened `DataMapper` per model), `user_ops.hpp` (principal→`user_id` resolution), and reusable `ledger_ops.hpp` (relation-aware debit/credit/post-entry + the `loadOwned` ownership guard). -- **`include/bank/models/` + `src/models/`** — one model per banking domain. The - `BRIDGE_REGISTER_*` macros live in the **model header** so every `.execute()` call - site sees the `ActionTraits` specialisation. +- **`include/bank/models/` + `src/models/`** — the models. The `BRIDGE_REGISTER_*` + macros live in the **model header** so every `.execute()` call site sees the + `ActionTraits` specialisation. `AccountModel` and `CustomerModel` are **stateful + and keyed** (see below); the remaining models are still per-domain and stateless. - **`src/db/schema.cpp`** — all `LIGHTWEIGHT_SQL_MIGRATION` table definitions. - **`include/bank/app/` + `src/app/`** — `App`: shared worker pool, GUI executor, `Bridge`, database setup, and login (which sets the bridge's default session). +### Stateful, keyed models + +`AccountModel` holds **one account, in memory**, for the lifetime of the instance. It +declares `using PrimaryKey = std::int64_t`, so morph keys instances by account id, and +`GetAccount`/`CloseAccount` declare that they carry that key (`BRIDGE_KEY_FROM`). Two +`BridgeHandler` handlers naming the same account — in one +GUI, or in two clients over one `RemoteServer` — reach a single instance and a single +balance. + +This is the shape morph is built around, and it is what makes the per-model strand +load-bearing: the instance owns mutable state, so its unsynchronised read-modify-write +is correct precisely because no two actions on one instance ever overlap. + +`CustomerModel` is the per-*owner* half that `AccountModel` used to also be doing: +`ListAccounts` and `OpenAccount` were never account-scoped, which is why both DTOs +carry an `owner` while `GetAccount`/`CloseAccount` carry an account id. It is keyed by +owner username. + +SQLite stays authoritative. The instance is a cache with identity: hydrated on first +use, written through on every mutation, dropped when the instance goes away. + +**The honest edge.** `Transfer`, bill payment and loan disbursement move money across +two accounts inside a single `SqlTransaction` owned by a *different* model, because +morph has no cross-instance transaction and this example must not imply it does. Those +writes land behind a cached row's back, so every balance write bumps a counter in +[`bank/db/row_versions.hpp`](include/bank/db/row_versions.hpp) and a cached reader +re-hydrates when the version it captured is stale. A real deployment would use the +store's own row version instead. + ### Why per-model `DataMapper`? morph runs each model on its own strand (single-threaded), so a model can own its own diff --git a/examples/bank/gui/controllers/AccountController.hpp b/examples/bank/gui/controllers/AccountController.hpp index d71345b7..84959aa9 100644 --- a/examples/bank/gui/controllers/AccountController.hpp +++ b/examples/bank/gui/controllers/AccountController.hpp @@ -9,7 +9,7 @@ #ifndef Q_MOC_RUN #include -#include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" #endif namespace bankgui { @@ -37,7 +37,7 @@ class AccountController : public BankController { void accountsChanged(); private: - morph::bridge::BridgeHandler _model; + morph::bridge::BridgeHandler _model; QVariantList _accounts; QString _totalBalance{QStringLiteral("—")}; int _openCount{0}; diff --git a/examples/bank/gui/controllers/CardController.hpp b/examples/bank/gui/controllers/CardController.hpp index fe0b34e7..4b0f083b 100644 --- a/examples/bank/gui/controllers/CardController.hpp +++ b/examples/bank/gui/controllers/CardController.hpp @@ -10,7 +10,7 @@ #ifndef Q_MOC_RUN #include -#include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" #include "bank/models/card_model.hpp" #endif @@ -44,7 +44,7 @@ class CardController : public BankController { void reloadCards(); void reloadAccounts(); - morph::bridge::BridgeHandler _accountModel; + morph::bridge::BridgeHandler _accountModel; morph::bridge::BridgeHandler _cardModel; QVariantList _cards; QVariantList _accounts; diff --git a/examples/bank/gui/controllers/LoanController.hpp b/examples/bank/gui/controllers/LoanController.hpp index 01878609..2890dbe5 100644 --- a/examples/bank/gui/controllers/LoanController.hpp +++ b/examples/bank/gui/controllers/LoanController.hpp @@ -10,7 +10,7 @@ #ifndef Q_MOC_RUN #include -#include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" #include "bank/models/loan_model.hpp" #endif @@ -47,7 +47,7 @@ class LoanController : public BankController { void reloadAccounts(); morph::bridge::BridgeHandler _loanModel; - morph::bridge::BridgeHandler _accountModel; + morph::bridge::BridgeHandler _accountModel; QVariantList _loans; QVariantList _accounts; QVariantList _schedule; diff --git a/examples/bank/gui/controllers/PayeeController.hpp b/examples/bank/gui/controllers/PayeeController.hpp index d1b49aba..2cfd3526 100644 --- a/examples/bank/gui/controllers/PayeeController.hpp +++ b/examples/bank/gui/controllers/PayeeController.hpp @@ -10,7 +10,7 @@ #ifndef Q_MOC_RUN #include -#include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" #include "bank/models/payee_model.hpp" #include "bank/models/payment_model.hpp" #endif @@ -46,7 +46,7 @@ class PayeeController : public BankController { void reloadAccounts(); morph::bridge::BridgeHandler _payeeModel; - morph::bridge::BridgeHandler _accountModel; + morph::bridge::BridgeHandler _accountModel; morph::bridge::BridgeHandler _paymentModel; QVariantList _payees; QVariantList _accounts; diff --git a/examples/bank/gui/controllers/TransactionController.hpp b/examples/bank/gui/controllers/TransactionController.hpp index a4b72f07..7f2d66c4 100644 --- a/examples/bank/gui/controllers/TransactionController.hpp +++ b/examples/bank/gui/controllers/TransactionController.hpp @@ -10,7 +10,7 @@ #ifndef Q_MOC_RUN #include -#include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" #include "bank/models/transaction_model.hpp" #endif @@ -48,7 +48,7 @@ class TransactionController : public BankController { void reloadHistory(); [[nodiscard]] int selectedCurrency() const; - morph::bridge::BridgeHandler _accountModel; + morph::bridge::BridgeHandler _accountModel; morph::bridge::BridgeHandler _txnModel; QVariantList _accounts; QVariantList _history; diff --git a/examples/bank/gui_wasm/CMakeLists.txt b/examples/bank/gui_wasm/CMakeLists.txt index cd9e5e5b..83a8b261 100644 --- a/examples/bank/gui_wasm/CMakeLists.txt +++ b/examples/bank/gui_wasm/CMakeLists.txt @@ -26,6 +26,7 @@ qt_add_executable(bank_gui_wasm # In-memory model implementations (persistence-free): src/models/auth_model_wasm.cpp src/models/account_model_wasm.cpp + src/models/customer_model_wasm.cpp src/models/transaction_model_wasm.cpp src/models/card_model_wasm.cpp src/models/payee_model_wasm.cpp diff --git a/examples/bank/gui_wasm/include/bank/models/account_model.hpp b/examples/bank/gui_wasm/include/bank/models/account_model.hpp index eb30441b..df32e45e 100644 --- a/examples/bank/gui_wasm/include/bank/models/account_model.hpp +++ b/examples/bank/gui_wasm/include/bank/models/account_model.hpp @@ -2,21 +2,36 @@ #pragma once // WASM shadow of include/bank/models/account_model.hpp (in-memory backend). +// Kept in lockstep with the desktop model: one account per instance, held in +// memory, keyed by account id. -#include +#include #include +#include +#include +#include #include "bank/dto/account_dto.hpp" +#include "bank/wasm/store.hpp" namespace bank { -/// @brief Opens, lists, inspects, and closes customer accounts (in-memory). +/// @brief One customer account, cached in the instance (in-memory backend). class AccountModel { public: - dto::AccountInfo execute(const dto::OpenAccount& action); - dto::AccountList execute(const dto::ListAccounts& action); + /// @brief Account id. Declaring this alias is what makes the model keyed. + using PrimaryKey = std::int64_t; + dto::AccountInfo execute(const dto::GetAccount& action); dto::CommandResult execute(const dto::CloseAccount& action); + +private: + void hydrate(std::int64_t accountId); + + wasm::AccountRow _row{}; + std::string _owner; + std::int64_t _loadedId = 0; + std::uint64_t _seenVersion = 0; }; } // namespace bank @@ -24,11 +39,10 @@ class AccountModel { using bank::AccountModel; using bank::dto::CloseAccount; using bank::dto::GetAccount; -using bank::dto::ListAccounts; -using bank::dto::OpenAccount; BRIDGE_REGISTER_MODEL(AccountModel, "AccountModel") -BRIDGE_REGISTER_ACTION(AccountModel, OpenAccount, "OpenAccount") -BRIDGE_REGISTER_ACTION(AccountModel, ListAccounts, "ListAccounts") BRIDGE_REGISTER_ACTION(AccountModel, GetAccount, "GetAccount") BRIDGE_REGISTER_ACTION(AccountModel, CloseAccount, "CloseAccount") + +BRIDGE_KEY_FROM(GetAccount, &GetAccount::id); +BRIDGE_KEY_FROM(CloseAccount, &CloseAccount::id); diff --git a/examples/bank/gui_wasm/include/bank/models/customer_model.hpp b/examples/bank/gui_wasm/include/bank/models/customer_model.hpp new file mode 100644 index 00000000..e969cf0e --- /dev/null +++ b/examples/bank/gui_wasm/include/bank/models/customer_model.hpp @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +// WASM shadow of include/bank/models/customer_model.hpp (in-memory backend). + +#include +#include +#include +#include + +#include "bank/dto/account_dto.hpp" + +namespace bank { + +/// @brief One customer: lists and opens the accounts they own (in-memory). +class CustomerModel { +public: + /// @brief Owner username. Declaring this alias is what makes the model keyed. + using PrimaryKey = std::string; + + dto::AccountInfo execute(const dto::OpenAccount& action); + dto::AccountList execute(const dto::ListAccounts& action); +}; + +} // namespace bank + +using bank::CustomerModel; +using bank::dto::ListAccounts; +using bank::dto::OpenAccount; + +BRIDGE_REGISTER_MODEL(CustomerModel, "CustomerModel") +BRIDGE_REGISTER_ACTION(CustomerModel, OpenAccount, "OpenAccount") +BRIDGE_REGISTER_ACTION(CustomerModel, ListAccounts, "ListAccounts") + +BRIDGE_KEY_FROM(ListAccounts, &ListAccounts::owner); +BRIDGE_KEY_FROM(OpenAccount, &OpenAccount::owner); diff --git a/examples/bank/gui_wasm/include/bank/wasm/store_ops.hpp b/examples/bank/gui_wasm/include/bank/wasm/store_ops.hpp index 2c87dff0..00452775 100644 --- a/examples/bank/gui_wasm/include/bank/wasm/store_ops.hpp +++ b/examples/bank/gui_wasm/include/bank/wasm/store_ops.hpp @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include "bank/db/row_versions.hpp" + #include #include #include @@ -109,6 +111,7 @@ inline TxnRow postEntry(Db& db, const AccountRow& account, TxnDirection directio inline TxnRow applyCredit(Db& db, AccountRow& account, std::int64_t amountMinor, TxnKind kind, std::int64_t counterpartyId, const std::string& description) { account.balanceMinor += amountMinor; + db::bumpRowVersion(static_cast(account.id)); db.accounts.update(account); return postEntry(db, account, TxnDirection::Credit, kind, amountMinor, counterpartyId, description); } @@ -122,6 +125,7 @@ inline TxnRow applyDebit(Db& db, AccountRow& account, std::int64_t amountMinor, throw InsufficientFunds{"amount exceeds available balance plus overdraft"}; } account.balanceMinor = projected; + db::bumpRowVersion(static_cast(account.id)); db.accounts.update(account); return postEntry(db, account, TxnDirection::Debit, kind, amountMinor, counterpartyId, description); } diff --git a/examples/bank/gui_wasm/src/models/account_model_wasm.cpp b/examples/bank/gui_wasm/src/models/account_model_wasm.cpp index eb74080d..1bc7eb09 100644 --- a/examples/bank/gui_wasm/src/models/account_model_wasm.cpp +++ b/examples/bank/gui_wasm/src/models/account_model_wasm.cpp @@ -1,16 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 // -// In-memory implementation of AccountModel for the WASM build. Mirrors the -// behaviour of src/models/account_model.cpp but persists to bank::wasm::Db. +// In-memory implementation of the stateful AccountModel for the WASM build. +// Mirrors src/models/account_model.cpp: one account per instance, cached in the +// instance, re-hydrated from bank::wasm::Db only when the row version moves. #include "bank/models/account_model.hpp" -#include #include #include "bank/core/errors.hpp" #include "bank/core/principal.hpp" #include "bank/core/types.hpp" +#include "bank/db/row_versions.hpp" #include "bank/wasm/store.hpp" #include "bank/wasm/store_ops.hpp" @@ -18,16 +19,6 @@ namespace bank { namespace { -std::string generateAccountNumber() { - static thread_local std::mt19937_64 rng{std::random_device{}()}; - std::uniform_int_distribution digit{0, 9}; - std::string number = "DE"; - for (int idx = 0; idx < 20; ++idx) { - number.push_back(static_cast('0' + digit(rng))); - } - return number; -} - dto::AccountInfo toInfo(const wasm::AccountRow& rec, const std::string& owner) { return dto::AccountInfo{ .id = static_cast(rec.id), @@ -42,66 +33,35 @@ dto::AccountInfo toInfo(const wasm::AccountRow& rec, const std::string& owner) { }; } -int defaultInterestBps(int kind) { - return kind == static_cast(AccountKind::Savings) ? 150 : 0; -} - } // namespace -dto::AccountInfo AccountModel::execute(const dto::OpenAccount& action) { - if (!action.validate()) { - throw ValidationError{"invalid account kind/currency/overdraft"}; - } - const std::string owner = resolveOwner(action.owner); - if (owner.empty()) { - throw Unauthorized{"no session principal to own the account"}; - } - auto& db = wasm::sharedDb(); - - wasm::AccountRow rec; - rec.userId = wasm::requireUserId(db, owner); - rec.number = generateAccountNumber(); - rec.kind = action.kind; - rec.currency = action.currency; - rec.balanceMinor = 0; - rec.overdraftMinor = action.overdraftMinor; - rec.status = static_cast(AccountStatus::Open); - rec.interestBps = defaultInterestBps(action.kind); - rec.id = db.accounts.insert(rec); - return toInfo(rec, owner); -} - -dto::AccountList AccountModel::execute(const dto::ListAccounts& action) { - const std::string owner = resolveOwner(action.owner); - if (owner.empty()) { - throw Unauthorized{"no session principal to list accounts for"}; - } - auto& db = wasm::sharedDb(); - const auto userId = wasm::requireUserId(db, owner); - - dto::AccountList out; - for (const auto& rec : db.accounts.where([&](const wasm::AccountRow& a) { return a.userId == userId; })) { - out.accounts.push_back(toInfo(rec, owner)); +void AccountModel::hydrate(std::int64_t accountId) { + const std::string owner = sessionPrincipal(); + const auto current = db::rowVersion(accountId); + if (_loadedId == accountId && _owner == owner && _seenVersion == current) { + return; } - return out; + auto& store = wasm::sharedDb(); + _row = wasm::loadOwnedAccount(store, accountId, wasm::requireUserId(store, owner)); + _owner = owner; + _loadedId = accountId; + _seenVersion = current; } dto::AccountInfo AccountModel::execute(const dto::GetAccount& action) { - const std::string owner = sessionPrincipal(); - auto& db = wasm::sharedDb(); - auto rec = wasm::loadOwnedAccount(db, action.id, wasm::requireUserId(db, owner)); - return toInfo(rec, owner); + hydrate(action.id); + return toInfo(_row, _owner); } dto::CommandResult AccountModel::execute(const dto::CloseAccount& action) { - const std::string owner = sessionPrincipal(); - auto& db = wasm::sharedDb(); - auto rec = wasm::loadOwnedAccount(db, action.id, wasm::requireUserId(db, owner)); - if (rec.balanceMinor != 0) { + hydrate(action.id); + if (_row.balanceMinor != 0) { return dto::CommandResult{.ok = false, .message = "account balance must be zero before closing"}; } - rec.status = static_cast(AccountStatus::Closed); - db.accounts.update(rec); + _row.status = static_cast(AccountStatus::Closed); + wasm::sharedDb().accounts.update(_row); + db::bumpRowVersion(action.id); + _seenVersion = db::rowVersion(action.id); return dto::CommandResult{.ok = true, .message = "account closed"}; } diff --git a/examples/bank/gui_wasm/src/models/customer_model_wasm.cpp b/examples/bank/gui_wasm/src/models/customer_model_wasm.cpp new file mode 100644 index 00000000..55a9b919 --- /dev/null +++ b/examples/bank/gui_wasm/src/models/customer_model_wasm.cpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// In-memory implementation of CustomerModel for the WASM build — the per-owner +// half of what used to be one AccountModel. Mirrors src/models/customer_model.cpp. + +#include "bank/models/customer_model.hpp" + +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/core/types.hpp" +#include "bank/wasm/store.hpp" +#include "bank/wasm/store_ops.hpp" + +namespace bank { + +namespace { + +std::string generateAccountNumber() { + static thread_local std::mt19937_64 rng{std::random_device{}()}; + std::uniform_int_distribution digit{0, 9}; + std::string number = "DE"; + for (int idx = 0; idx < 20; ++idx) { + number.push_back(static_cast('0' + digit(rng))); + } + return number; +} + +dto::AccountInfo toInfo(const wasm::AccountRow& rec, const std::string& owner) { + return dto::AccountInfo{ + .id = static_cast(rec.id), + .owner = owner, + .number = rec.number, + .kind = rec.kind, + .currency = rec.currency, + .balanceMinor = rec.balanceMinor, + .overdraftMinor = rec.overdraftMinor, + .status = rec.status, + .interestBps = rec.interestBps, + }; +} + +int defaultInterestBps(int kind) { return kind == static_cast(AccountKind::Savings) ? 150 : 0; } + +} // namespace + +dto::AccountInfo CustomerModel::execute(const dto::OpenAccount& action) { + if (!action.validate()) { + throw ValidationError{"invalid account kind/currency/overdraft"}; + } + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal to own the account"}; + } + auto& store = wasm::sharedDb(); + + wasm::AccountRow rec; + rec.userId = wasm::requireUserId(store, owner); + rec.number = generateAccountNumber(); + rec.kind = action.kind; + rec.currency = action.currency; + rec.balanceMinor = 0; + rec.overdraftMinor = action.overdraftMinor; + rec.status = static_cast(AccountStatus::Open); + rec.interestBps = defaultInterestBps(action.kind); + rec.id = store.accounts.insert(rec); + return toInfo(rec, owner); +} + +dto::AccountList CustomerModel::execute(const dto::ListAccounts& action) { + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal to list accounts for"}; + } + auto& store = wasm::sharedDb(); + const auto userId = wasm::requireUserId(store, owner); + + dto::AccountList out; + for (const auto& rec : store.accounts.where([&](const wasm::AccountRow& row) { return row.userId == userId; })) { + out.accounts.push_back(toInfo(rec, owner)); + } + return out; +} + +} // namespace bank diff --git a/examples/bank/include/bank/db/account_mapping.hpp b/examples/bank/include/bank/db/account_mapping.hpp new file mode 100644 index 00000000..f615a027 --- /dev/null +++ b/examples/bank/include/bank/db/account_mapping.hpp @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include "bank/db/entities.hpp" +#include "bank/dto/account_dto.hpp" + +/// @file +/// Shared `AccountRecord` -> `AccountInfo` projection. Split out of +/// `account_model.cpp` when the model divided into `AccountModel` (per account) +/// and `CustomerModel` (per owner), so both halves project rows identically. + +namespace bank::db { + +/// @brief Translates a persisted `AccountRecord` into the wire `AccountInfo` DTO. +/// @param rec Persisted account row. +/// @param owner Resolved owner username — the wire DTO carries the username +/// rather than the internal `user_id` the record stores. +/// @return The wire projection of @p rec. +[[nodiscard]] inline dto::AccountInfo toAccountInfo(const AccountRecord& rec, const std::string& owner) { + return dto::AccountInfo{ + .id = static_cast(rec.id.Value()), + .owner = owner, + .number = std::string{rec.number.Value().str()}, + .kind = rec.kind.Value(), + .currency = rec.currency.Value(), + .balanceMinor = rec.balanceMinor.Value(), + .overdraftMinor = rec.overdraftMinor.Value(), + .status = rec.status.Value(), + .interestBps = rec.interestBps.Value(), + }; +} + +} // namespace bank::db diff --git a/examples/bank/include/bank/db/ledger_ops.hpp b/examples/bank/include/bank/db/ledger_ops.hpp index e8048d7d..d7506183 100644 --- a/examples/bank/include/bank/db/ledger_ops.hpp +++ b/examples/bank/include/bank/db/ledger_ops.hpp @@ -11,6 +11,7 @@ #include "bank/core/errors.hpp" #include "bank/core/types.hpp" #include "bank/db/entities.hpp" +#include "bank/db/row_versions.hpp" #include "bank/db/user_ops.hpp" /// @file @@ -120,6 +121,7 @@ inline TxnRecord applyCredit(Lightweight::DataMapper& mapper, AccountRecord& acc std::int64_t amountMinor, TxnKind kind, std::int64_t counterpartyId, const std::string& description) { account.balanceMinor = account.balanceMinor.Value() + amountMinor; + bumpRowVersion(static_cast(account.id.Value())); mapper.Update(account); return postEntry(mapper, account, TxnDirection::Credit, kind, amountMinor, counterpartyId, description); } @@ -135,6 +137,7 @@ inline TxnRecord applyDebit(Lightweight::DataMapper& mapper, AccountRecord& acco throw InsufficientFunds{"amount exceeds available balance plus overdraft"}; } account.balanceMinor = projected; + bumpRowVersion(static_cast(account.id.Value())); mapper.Update(account); return postEntry(mapper, account, TxnDirection::Debit, kind, amountMinor, counterpartyId, description); } diff --git a/examples/bank/include/bank/db/row_versions.hpp b/examples/bank/include/bank/db/row_versions.hpp new file mode 100644 index 00000000..e9918ab5 --- /dev/null +++ b/examples/bank/include/bank/db/row_versions.hpp @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +/// @file +/// Per-row version counters, so a *stateful* model can tell when a row it is +/// holding in memory was changed by somebody else. +/// +/// A keyed `AccountModel` instance owns one account and keeps its row in memory +/// — that is what makes reads free and what makes sharing the instance worth +/// anything. But the bank's ledger operations (transfer, bill payment, loan +/// disbursement) deliberately move money inside a single `SqlTransaction` owned +/// by a *different* model, because morph has no cross-instance transaction and +/// this example must not pretend otherwise (see +/// docs/planned/stateful_bank_example.md). Those writes land in SQLite behind +/// the cached row's back. +/// +/// This is the smallest honest fix: every writer bumps the account's version, +/// and a cached reader re-hydrates when the version it captured is stale. It is +/// process-wide because the models it coordinates share one process; a real +/// deployment would use the store's own row version or an optimistic-concurrency +/// column instead. + +namespace bank::db { + +/// @brief Process-wide monotonic version counters keyed by account id. +/// +/// Thread-safe: models run on their own strands, but different models run +/// concurrently, so bumps and reads genuinely race. +class RowVersions { +public: + /// @brief Returns the process-wide instance. + /// @return Reference to the singleton. + static RowVersions& instance() { + static RowVersions inst; + return inst; + } + + /// @brief Records that the row for @p accountId changed. + /// @param accountId Account whose row was written. + void bump(std::int64_t accountId) { + std::scoped_lock const lock{_mtx}; + _versions[accountId] += 1; + } + + /// @brief Current version of @p accountId's row. + /// @param accountId Account to query. + /// @return A counter that changes whenever the row is written; `0` if never written. + [[nodiscard]] std::uint64_t version(std::int64_t accountId) { + std::scoped_lock const lock{_mtx}; + auto iter = _versions.find(accountId); + return iter == _versions.end() ? 0U : iter->second; + } + +private: + std::mutex _mtx; + std::unordered_map _versions; +}; + +/// @brief Convenience wrapper for `RowVersions::instance().bump(accountId)`. +/// @param accountId Account whose row was written. +inline void bumpRowVersion(std::int64_t accountId) { RowVersions::instance().bump(accountId); } + +/// @brief Convenience wrapper for `RowVersions::instance().version(accountId)`. +/// @param accountId Account to query. +/// @return The account's current row version. +[[nodiscard]] inline std::uint64_t rowVersion(std::int64_t accountId) { + return RowVersions::instance().version(accountId); +} + +} // namespace bank::db diff --git a/examples/bank/include/bank/models/account_model.hpp b/examples/bank/include/bank/models/account_model.hpp index 881cff43..8dfade4f 100644 --- a/examples/bank/include/bank/models/account_model.hpp +++ b/examples/bank/include/bank/models/account_model.hpp @@ -1,34 +1,54 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once -#include +#include +#include #include +#include +#include #include "bank/db/db_model.hpp" +#include "bank/db/entities.hpp" #include "bank/dto/account_dto.hpp" /// @file -/// The Account model. A plain, single-threaded C++ class — morph runs each -/// instance on its own strand, so the model never deals with concurrency. It -/// owns one Lightweight `DataMapper` (lazily opened on first use, on the strand -/// thread) and translates between wire DTOs and the `AccountRecord` entity. +/// The Account model — one customer account, **held in memory** for the lifetime +/// of the instance. +/// +/// This is the shape morph is designed around and the reason the framework runs +/// each model on its own strand: the instance owns mutable state, so an +/// unsynchronised read-modify-write of `_row` is correct precisely because no +/// two actions on the same instance ever overlap. A stateless model (as every +/// bank model used to be) makes that strand protect nothing. +/// +/// The model declares `PrimaryKey`, so morph keys instances by account id: two +/// `BridgeHandler` handlers naming the same account — +/// in one GUI or in two clients — reach one instance and see one balance. +/// SQLite stays authoritative; the instance is a cache with identity, hydrated +/// on first use and written through on every mutation. namespace bank { -/// @brief Opens, lists, inspects, and closes customer accounts. +/// @brief One customer account: its row, cached, with reads served from memory. class AccountModel : private db::WithMapper { public: - /// @brief Opens a new account; returns the freshly created account. - dto::AccountInfo execute(const dto::OpenAccount& action); - - /// @brief Lists accounts owned by the requested (or session) owner. - dto::AccountList execute(const dto::ListAccounts& action); + /// @brief Account id. Declaring this alias is what makes the model keyed. + using PrimaryKey = std::int64_t; - /// @brief Returns one account by id, or throws `NotFound`. + /// @brief Returns the account, hydrating from SQLite only when needed. dto::AccountInfo execute(const dto::GetAccount& action); /// @brief Closes a zero-balance account; returns ok/message. dto::CommandResult execute(const dto::CloseAccount& action); + +private: + /// @brief Loads `_row` for @p accountId if it is absent, stale, or for another account. + void hydrate(std::int64_t accountId); + + db::AccountRecord _row{}; ///< the account itself — in memory, not re-queried + std::string _owner; ///< resolved owner username for `_row` + std::int64_t _loadedId = 0; ///< which account `_row` holds; 0 = none + std::uint64_t _seenVersion = 0; ///< row version `_row` was hydrated at }; } // namespace bank @@ -37,18 +57,16 @@ class AccountModel : private db::WithMapper { // Registration lives in the header (not the .cpp) on purpose: the // BRIDGE_REGISTER_ACTION macro specialises `morph::model::ActionTraits`, // and every translation unit that calls `handler.execute(Action{...})` needs -// that specialisation visible to deduce the result type. The macros must sit at -// global scope and token-paste unqualified identifiers, so the types are pulled -// in with using-declarations first. The static registration runs once per -// including TU; the underlying registry assignment is idempotent. +// that specialisation visible to deduce the result type. using bank::AccountModel; using bank::dto::CloseAccount; using bank::dto::GetAccount; -using bank::dto::ListAccounts; -using bank::dto::OpenAccount; BRIDGE_REGISTER_MODEL(AccountModel, "AccountModel") -BRIDGE_REGISTER_ACTION(AccountModel, OpenAccount, "OpenAccount") -BRIDGE_REGISTER_ACTION(AccountModel, ListAccounts, "ListAccounts", ::morph::model::Loggable::No) BRIDGE_REGISTER_ACTION(AccountModel, GetAccount, "GetAccount", ::morph::model::Loggable::No) BRIDGE_REGISTER_ACTION(AccountModel, CloseAccount, "CloseAccount") + +// Both actions name the account they act on, so a shared handler attaches (or +// re-points) to that account's instance on the way through. +BRIDGE_KEY_FROM(GetAccount, &GetAccount::id); +BRIDGE_KEY_FROM(CloseAccount, &CloseAccount::id); diff --git a/examples/bank/include/bank/models/customer_model.hpp b/examples/bank/include/bank/models/customer_model.hpp new file mode 100644 index 00000000..718bc983 --- /dev/null +++ b/examples/bank/include/bank/models/customer_model.hpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include + +#include "bank/db/db_model.hpp" +#include "bank/dto/account_dto.hpp" + +/// @file +/// The Customer model — the *per-owner* half of what used to be one +/// `AccountModel` doing two unrelated jobs. +/// +/// `ListAccounts` and `OpenAccount` were never account-scoped operations: they +/// are scoped by owner, which is exactly why both DTOs carry an `owner` field +/// while `GetAccount`/`CloseAccount` carry an account id. Splitting the model by +/// the entity it is actually about is what gives each half a primary key that +/// identifies something. + +namespace bank { + +/// @brief One customer: lists and opens the accounts they own. +class CustomerModel : private db::WithMapper { +public: + /// @brief Owner username. Declaring this alias is what makes the model keyed. + using PrimaryKey = std::string; + + /// @brief Opens a new account for the requested (or session) owner. + dto::AccountInfo execute(const dto::OpenAccount& action); + + /// @brief Lists accounts owned by the requested (or session) owner. + dto::AccountList execute(const dto::ListAccounts& action); +}; + +} // namespace bank + +using bank::CustomerModel; +using bank::dto::ListAccounts; +using bank::dto::OpenAccount; + +BRIDGE_REGISTER_MODEL(CustomerModel, "CustomerModel") +BRIDGE_REGISTER_ACTION(CustomerModel, OpenAccount, "OpenAccount") +BRIDGE_REGISTER_ACTION(CustomerModel, ListAccounts, "ListAccounts", ::morph::model::Loggable::No) + +// Keyed by owner. An empty `owner` means "the session principal", which is not +// a key the directory can share on, so such a call simply runs on whatever +// instance the handler already holds. +BRIDGE_KEY_FROM(ListAccounts, &ListAccounts::owner); +BRIDGE_KEY_FROM(OpenAccount, &OpenAccount::owner); diff --git a/examples/bank/src/cli/main.cpp b/examples/bank/src/cli/main.cpp index 3ba0026b..abcb1b3d 100644 --- a/examples/bank/src/cli/main.cpp +++ b/examples/bank/src/cli/main.cpp @@ -36,7 +36,7 @@ #include "bank/dto/payment_dto.hpp" #include "bank/dto/statement_dto.hpp" #include "bank/dto/transaction_dto.hpp" -#include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" #include "bank/models/auth_model.hpp" #include "bank/models/budget_model.hpp" #include "bank/models/card_model.hpp" @@ -79,7 +79,7 @@ void runScenario(morph::bridge::Bridge& bridge, morph::exec::MainThreadExecutor& std::println("\n========== {} ==========", label); morph::bridge::BridgeHandler auth{bridge, &gui}; - morph::bridge::BridgeHandler accounts{bridge, &gui}; + morph::bridge::BridgeHandler accounts{bridge, &gui}; morph::bridge::BridgeHandler txns{bridge, &gui}; morph::bridge::BridgeHandler payees{bridge, &gui}; morph::bridge::BridgeHandler payments{bridge, &gui}; diff --git a/examples/bank/src/models/account_model.cpp b/examples/bank/src/models/account_model.cpp index 887aeb4e..e36a3b57 100644 --- a/examples/bank/src/models/account_model.cpp +++ b/examples/bank/src/models/account_model.cpp @@ -4,115 +4,56 @@ #include #include -#include #include #include "bank/core/errors.hpp" #include "bank/core/principal.hpp" #include "bank/core/types.hpp" +#include "bank/db/account_mapping.hpp" #include "bank/db/ledger_ops.hpp" -#include "bank/db/user_ops.hpp" +#include "bank/db/row_versions.hpp" namespace bank { -namespace { - -/// Generates a pseudo-IBAN-ish account number: "DE" + 20 digits. Good enough -/// for an example; not a real check-digit-valid IBAN. -std::string generateAccountNumber() { - static thread_local std::mt19937_64 rng{std::random_device{}()}; - std::uniform_int_distribution digit{0, 9}; - std::string number = "DE"; - for (int idx = 0; idx < 20; ++idx) { - number.push_back(static_cast('0' + digit(rng))); - } - return number; -} - -/// Translates a persisted `AccountRecord` into the wire `AccountInfo` DTO. -/// @p owner is the resolved owner username (the wire DTO carries the username -/// rather than the internal `user_id` the record stores). -dto::AccountInfo toInfo(const db::AccountRecord& rec, const std::string& owner) { - return dto::AccountInfo{ - .id = static_cast(rec.id.Value()), - .owner = owner, - .number = std::string{rec.number.Value().str()}, - .kind = rec.kind.Value(), - .currency = rec.currency.Value(), - .balanceMinor = rec.balanceMinor.Value(), - .overdraftMinor = rec.overdraftMinor.Value(), - .status = rec.status.Value(), - .interestBps = rec.interestBps.Value(), - }; -} - -/// Default annual interest for savings accounts (1.5% = 150 bps); others earn 0. -int defaultInterestBps(int kind) { return kind == static_cast(AccountKind::Savings) ? 150 : 0; } - -} // namespace - -dto::AccountInfo AccountModel::execute(const dto::OpenAccount& action) { - if (!action.validate()) { - throw ValidationError{"invalid account kind/currency/overdraft"}; - } - const std::string owner = resolveOwner(action.owner); - if (owner.empty()) { - throw Unauthorized{"no session principal to own the account"}; - } - - db::AccountRecord rec; - db::setReference(rec.user, db::requireUserId(mapper(), owner)); - rec.number = Light::SqlAnsiString<34>{generateAccountNumber()}; - rec.kind = action.kind; - rec.currency = action.currency; - rec.balanceMinor = 0; - rec.overdraftMinor = action.overdraftMinor; - rec.status = static_cast(AccountStatus::Open); - rec.interestBps = defaultInterestBps(action.kind); - - mapper().Create(rec); - return toInfo(rec, owner); -} - -dto::AccountList AccountModel::execute(const dto::ListAccounts& action) { - const std::string owner = resolveOwner(action.owner); - if (owner.empty()) { - throw Unauthorized{"no session principal to list accounts for"}; - } - - // Load the owner and walk the `UserRecord::accounts` HasMany relation rather - // than issuing a manual `WHERE user_id = ?` — the relation resolves the join - // for us and returns the user's accounts directly. - const auto userId = db::requireUserId(mapper(), owner); - auto user = mapper().QuerySingle(userId).value(); - - dto::AccountList out; - out.accounts.reserve(user.accounts.Count()); - for (const auto& account : user.accounts.All()) { - out.accounts.push_back(toInfo(*account, owner)); +void AccountModel::hydrate(std::int64_t accountId) { + const std::string owner = sessionPrincipal(); + // Re-read only when we have to: a different account, nothing cached yet, a + // different principal asking, or somebody else wrote the row (a transfer or + // bill payment settling on another model's connection — see + // db/row_versions.hpp). Otherwise the answer is already in memory, which is + // the entire point of a stateful model. + const auto current = db::rowVersion(accountId); + if (_loadedId == accountId && _owner == owner && _seenVersion == current) { + return; } - return out; + _row = db::loadOwned(mapper(), accountId, owner, "account"); + _owner = owner; + _loadedId = accountId; + _seenVersion = current; } dto::AccountInfo AccountModel::execute(const dto::GetAccount& action) { - const std::string owner = sessionPrincipal(); - auto rec = db::loadOwned(mapper(), action.id, owner, "account"); - return toInfo(rec, owner); + hydrate(action.id); + return db::toAccountInfo(_row, _owner); } dto::CommandResult AccountModel::execute(const dto::CloseAccount& action) { - auto rec = db::loadOwned(mapper(), action.id, sessionPrincipal(), "account"); + hydrate(action.id); // Best-effort zero-balance guard. The balance is read on this model's own // connection, so a deposit committing on another model's connection between // this read and the Update could leave a Closed account holding funds — the // same cross-connection window documented in ledger_ops.hpp. A production // ledger would close the account inside the same transaction that settles // its balance, or gate on an atomic conditional update. - if (rec.balanceMinor.Value() != 0) { + if (_row.balanceMinor.Value() != 0) { return dto::CommandResult{.ok = false, .message = "account balance must be zero before closing"}; } - rec.status = static_cast(AccountStatus::Closed); - mapper().Update(rec); + _row.status = static_cast(AccountStatus::Closed); + mapper().Update(_row); + // Write through, then publish the new version so any other cached holder of + // this row re-hydrates rather than serving a stale status. + db::bumpRowVersion(action.id); + _seenVersion = db::rowVersion(action.id); return dto::CommandResult{.ok = true, .message = "account closed"}; } diff --git a/examples/bank/src/models/customer_model.cpp b/examples/bank/src/models/customer_model.cpp new file mode 100644 index 00000000..e1be69be --- /dev/null +++ b/examples/bank/src/models/customer_model.cpp @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "bank/models/customer_model.hpp" + +#include +#include +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/core/types.hpp" +#include "bank/db/account_mapping.hpp" +#include "bank/db/ledger_ops.hpp" +#include "bank/db/user_ops.hpp" + +namespace bank { + +namespace { + +/// Generates a pseudo-IBAN-ish account number: "DE" + 20 digits. Good enough +/// for an example; not a real check-digit-valid IBAN. +std::string generateAccountNumber() { + static thread_local std::mt19937_64 rng{std::random_device{}()}; + std::uniform_int_distribution digit{0, 9}; + std::string number = "DE"; + for (int idx = 0; idx < 20; ++idx) { + number.push_back(static_cast('0' + digit(rng))); + } + return number; +} + +/// Default annual interest for savings accounts (1.5% = 150 bps); others earn 0. +int defaultInterestBps(int kind) { return kind == static_cast(AccountKind::Savings) ? 150 : 0; } + +} // namespace + +dto::AccountInfo CustomerModel::execute(const dto::OpenAccount& action) { + if (!action.validate()) { + throw ValidationError{"invalid account kind/currency/overdraft"}; + } + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal to own the account"}; + } + + db::AccountRecord rec; + db::setReference(rec.user, db::requireUserId(mapper(), owner)); + rec.number = Light::SqlAnsiString<34>{generateAccountNumber()}; + rec.kind = action.kind; + rec.currency = action.currency; + rec.balanceMinor = 0; + rec.overdraftMinor = action.overdraftMinor; + rec.status = static_cast(AccountStatus::Open); + rec.interestBps = defaultInterestBps(action.kind); + + mapper().Create(rec); + return db::toAccountInfo(rec, owner); +} + +dto::AccountList CustomerModel::execute(const dto::ListAccounts& action) { + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal to list accounts for"}; + } + + // Load the owner and walk the `UserRecord::accounts` HasMany relation rather + // than issuing a manual `WHERE user_id = ?` — the relation resolves the join + // for us and returns the user's accounts directly. + const auto userId = db::requireUserId(mapper(), owner); + auto user = mapper().QuerySingle(userId).value(); + + dto::AccountList out; + out.accounts.reserve(user.accounts.Count()); + for (const auto& account : user.accounts.All()) { + out.accounts.push_back(db::toAccountInfo(*account, owner)); + } + return out; +} + +} // namespace bank diff --git a/examples/bank/tests/test_account.cpp b/examples/bank/tests/test_account.cpp index 4f3cc6e0..a40083b4 100644 --- a/examples/bank/tests/test_account.cpp +++ b/examples/bank/tests/test_account.cpp @@ -10,6 +10,7 @@ #include "bank/core/types.hpp" #include "bank/dto/account_dto.hpp" #include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" #include "bank_test_support.hpp" using bank::testing::await; @@ -29,9 +30,10 @@ TEST_CASE("AccountModel opens, lists, fetches and closes accounts", "[account]") app.login("alice-account-basic"); morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler accountsOwner{app.bridge(), app.gui()}; SECTION("open populates an account with a number and zero balance") { - auto info = await(accounts.execute(bank::dto::OpenAccount{ + auto info = await(accountsOwner.execute(bank::dto::OpenAccount{ .owner = "", .kind = static_cast(bank::AccountKind::Checking), .currency = static_cast(bank::Currency::EUR), @@ -49,7 +51,7 @@ TEST_CASE("AccountModel opens, lists, fetches and closes accounts", "[account]") } SECTION("savings accounts get a non-zero interest rate") { - auto info = await(accounts.execute(bank::dto::OpenAccount{ + auto info = await(accountsOwner.execute(bank::dto::OpenAccount{ .kind = static_cast(bank::AccountKind::Savings), .currency = static_cast(bank::Currency::USD), }), @@ -58,10 +60,10 @@ TEST_CASE("AccountModel opens, lists, fetches and closes accounts", "[account]") } SECTION("list returns only the session owner's accounts") { - await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()); - await(accounts.execute(bank::dto::OpenAccount{.kind = 1, .currency = 0}), app.guiLoop()); + await(accountsOwner.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()); + await(accountsOwner.execute(bank::dto::OpenAccount{.kind = 1, .currency = 0}), app.guiLoop()); - auto list = await(accounts.execute(bank::dto::ListAccounts{}), app.guiLoop()); + auto list = await(accountsOwner.execute(bank::dto::ListAccounts{}), app.guiLoop()); REQUIRE(list.accounts.size() >= 2); for (const auto& acct : list.accounts) { REQUIRE(acct.owner == "alice-account-basic"); @@ -69,14 +71,14 @@ TEST_CASE("AccountModel opens, lists, fetches and closes accounts", "[account]") } SECTION("get returns the same account that was opened") { - auto opened = await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()); + auto opened = await(accountsOwner.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()); auto fetched = await(accounts.execute(bank::dto::GetAccount{.id = opened.id}), app.guiLoop()); REQUIRE(fetched.id == opened.id); REQUIRE(fetched.number == opened.number); } SECTION("closing a zero-balance account succeeds") { - auto opened = await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()); + auto opened = await(accountsOwner.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()); auto result = await(accounts.execute(bank::dto::CloseAccount{.id = opened.id}), app.guiLoop()); REQUIRE(result.ok); @@ -89,6 +91,7 @@ TEST_CASE("AccountModel reports errors through onError", "[account]") { bank::app::App app{dbConnectionForTests()}; app.login("bob-account-errors"); morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler accountsOwner{app.bridge(), app.gui()}; SECTION("fetching a non-existent account throws NotFound") { REQUIRE_THROWS_AS(await(accounts.execute(bank::dto::GetAccount{.id = 999999}), app.guiLoop()), bank::NotFound); @@ -99,7 +102,7 @@ TEST_CASE("AccountModel reports errors through onError", "[account]") { // so morph's ActionValidator gate catches this before AccountModel:: // execute() runs -- see docs/spec/forms/forms.md, "Security / trust // boundary". - REQUIRE_THROWS_AS(await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 99}), app.guiLoop()), + REQUIRE_THROWS_AS(await(accountsOwner.execute(bank::dto::OpenAccount{.kind = 0, .currency = 99}), app.guiLoop()), morph::model::ValidationError); } } diff --git a/examples/bank/tests/test_budget.cpp b/examples/bank/tests/test_budget.cpp index ca1367c6..cac89d47 100644 --- a/examples/bank/tests/test_budget.cpp +++ b/examples/bank/tests/test_budget.cpp @@ -12,7 +12,7 @@ #include "bank/dto/account_dto.hpp" #include "bank/dto/budget_dto.hpp" #include "bank/dto/transaction_dto.hpp" -#include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" #include "bank/models/budget_model.hpp" #include "bank/models/transaction_model.hpp" #include "bank_test_support.hpp" @@ -32,7 +32,7 @@ std::string testConnection() { TEST_CASE("BudgetModel upserts budgets and computes spending", "[budget]") { bank::app::App app{testConnection()}; app.login("laura-budget"); - morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; morph::bridge::BridgeHandler txns{app.bridge(), app.gui()}; morph::bridge::BridgeHandler budgets{app.bridge(), app.gui()}; diff --git a/examples/bank/tests/test_card.cpp b/examples/bank/tests/test_card.cpp index 5d707330..2a712a9e 100644 --- a/examples/bank/tests/test_card.cpp +++ b/examples/bank/tests/test_card.cpp @@ -10,7 +10,7 @@ #include "bank/core/types.hpp" #include "bank/dto/account_dto.hpp" #include "bank/dto/card_dto.hpp" -#include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" #include "bank/models/card_model.hpp" #include "bank_test_support.hpp" @@ -28,7 +28,7 @@ std::string testConnection() { TEST_CASE("CardModel issues and manages cards", "[card]") { bank::app::App app{testConnection()}; app.login("judy-card"); - morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; morph::bridge::BridgeHandler cards{app.bridge(), app.gui()}; const auto account = await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()).id; diff --git a/examples/bank/tests/test_loan.cpp b/examples/bank/tests/test_loan.cpp index eaaa73ba..ffb2fee8 100644 --- a/examples/bank/tests/test_loan.cpp +++ b/examples/bank/tests/test_loan.cpp @@ -13,6 +13,7 @@ #include "bank/dto/account_dto.hpp" #include "bank/dto/loan_dto.hpp" #include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" #include "bank/models/loan_model.hpp" #include "bank_test_support.hpp" @@ -32,10 +33,11 @@ TEST_CASE("LoanModel disburses, schedules, and repays", "[loan]") { bank::app::App app{testConnection()}; app.login("ken-loan"); morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler accountsOwner{app.bridge(), app.gui()}; morph::bridge::BridgeHandler loans{app.bridge(), app.gui()}; const auto account = - await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()).id; + await(accountsOwner.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()).id; SECTION("applying disburses the principal into the account") { auto loan = await(loans.execute(bank::dto::ApplyLoan{.accountId = account, diff --git a/examples/bank/tests/test_offline.cpp b/examples/bank/tests/test_offline.cpp index 05b58488..05c9e76b 100644 --- a/examples/bank/tests/test_offline.cpp +++ b/examples/bank/tests/test_offline.cpp @@ -19,6 +19,7 @@ #include "bank/dto/account_dto.hpp" #include "bank/dto/transaction_dto.hpp" #include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" #include "bank/models/transaction_model.hpp" #include "bank_test_support.hpp" @@ -38,10 +39,11 @@ TEST_CASE("Offline deposits are queued and replayed on reconnect", "[offline]") bank::app::App app{testConnection()}; app.login("peter-offline"); morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler accountsOwner{app.bridge(), app.gui()}; morph::bridge::BridgeHandler txns{app.bridge(), app.gui()}; const auto acct = - await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()).id; + await(accountsOwner.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()).id; // --- While "offline": park deposits in the durable queue instead of sending. morph::offline::InMemoryOfflineQueue queue; diff --git a/examples/bank/tests/test_payment.cpp b/examples/bank/tests/test_payment.cpp index f91c5c4c..52c78307 100644 --- a/examples/bank/tests/test_payment.cpp +++ b/examples/bank/tests/test_payment.cpp @@ -14,6 +14,7 @@ #include "bank/dto/payee_dto.hpp" #include "bank/dto/payment_dto.hpp" #include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" #include "bank/models/payee_model.hpp" #include "bank/models/payment_model.hpp" #include "bank/models/transaction_model.hpp" @@ -35,12 +36,13 @@ TEST_CASE("PaymentModel pays bills, schedules, and cancels", "[payment]") { bank::app::App app{testConnection()}; app.login("ivan-pay"); morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler accountsOwner{app.bridge(), app.gui()}; morph::bridge::BridgeHandler payees{app.bridge(), app.gui()}; morph::bridge::BridgeHandler txns{app.bridge(), app.gui()}; morph::bridge::BridgeHandler payments{app.bridge(), app.gui()}; const auto account = - await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()).id; + await(accountsOwner.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()).id; await(txns.execute(bank::dto::Deposit{.accountId = account, .amountMinor = 50000}), app.guiLoop()); const auto payee = await(payees.execute(bank::dto::AddPayee{.name = "Electric Co", .iban = "DE89370400440532013000"}), diff --git a/examples/bank/tests/test_relations.cpp b/examples/bank/tests/test_relations.cpp index eb6c6141..3ac5219b 100644 --- a/examples/bank/tests/test_relations.cpp +++ b/examples/bank/tests/test_relations.cpp @@ -28,7 +28,7 @@ #include "bank/dto/payee_dto.hpp" #include "bank/dto/payment_dto.hpp" #include "bank/dto/transaction_dto.hpp" -#include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" #include "bank/models/card_model.hpp" #include "bank/models/payee_model.hpp" #include "bank/models/payment_model.hpp" @@ -52,7 +52,7 @@ TEST_CASE("ORM relations: BelongsTo navigation and HasMany inverses", "[relation bank::app::App app{dbConnectionForTests()}; app.login(principal); // provisions the users row - morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; morph::bridge::BridgeHandler cards{app.bridge(), app.gui()}; morph::bridge::BridgeHandler payees{app.bridge(), app.gui()}; morph::bridge::BridgeHandler payments{app.bridge(), app.gui()}; diff --git a/examples/bank/tests/test_remote.cpp b/examples/bank/tests/test_remote.cpp index d59a98bf..927a671f 100644 --- a/examples/bank/tests/test_remote.cpp +++ b/examples/bank/tests/test_remote.cpp @@ -20,6 +20,7 @@ #include "bank/core/types.hpp" #include "bank/dto/account_dto.hpp" #include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" #include "bank_test_support.hpp" using bank::testing::await; @@ -73,9 +74,10 @@ TEST_CASE("AccountModel runs unchanged over a remote backend", "[remote]") { bank::testing::ensurePrincipal("olivia-remote"); morph::bridge::BridgeHandler accounts{bridge, &gui}; + morph::bridge::BridgeHandler accountsOwner{bridge, &gui}; SECTION("opening an account round-trips through JSON serialisation") { - auto info = await(accounts.execute(bank::dto::OpenAccount{ + auto info = await(accountsOwner.execute(bank::dto::OpenAccount{ .kind = static_cast(bank::AccountKind::Savings), .currency = static_cast(bank::Currency::GBP), }), @@ -86,7 +88,7 @@ TEST_CASE("AccountModel runs unchanged over a remote backend", "[remote]") { } SECTION("the authorizer rejects the forbidden action") { - auto info = await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), gui); + auto info = await(accountsOwner.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), gui); REQUIRE_THROWS(await(accounts.execute(bank::dto::CloseAccount{.id = info.id}), gui)); } } diff --git a/examples/bank/tests/test_stateful_account.cpp b/examples/bank/tests/test_stateful_account.cpp new file mode 100644 index 00000000..857e1d71 --- /dev/null +++ b/examples/bank/tests/test_stateful_account.cpp @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Tests for the stateful, keyed AccountModel (F1). +// +// The point of the reshape is that an account instance *holds* its row rather +// than re-querying it, and that two handlers naming the same account reach one +// instance. A stateless model would satisfy none of these assertions +// non-vacuously — the balance would simply be re-read from SQLite every time. +// +// See docs/planned/stateful_bank_example.md. + +#include +#include +#include +#include +#include + +#include "bank/app/app.hpp" +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/transaction_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" +#include "bank/models/transaction_model.hpp" +#include "bank_test_support.hpp" + +using bank::testing::await; +using morph::bridge::AllowShared; +using morph::bridge::BridgeHandler; + +namespace { + +std::string statefulTestConnection() { + bank::testing::ensureDatabase(); + return "DRIVER=SQLite3;Database=" + (std::filesystem::temp_directory_path() / "morph_bank_tests.db").string(); +} + +/// Opens a checking account for the logged-in principal and returns its id. +std::int64_t openChecking(bank::app::App& app, BridgeHandler& customer) { + auto info = await(customer.execute(bank::dto::OpenAccount{ + .kind = static_cast(bank::AccountKind::Checking), + .currency = static_cast(bank::Currency::USD), + .overdraftMinor = 0, + }), + app.guiLoop()); + return info.id; +} + +} // namespace + +TEST_CASE("two shared handlers on one account reach one instance", "[stateful-account]") { + bank::app::App app{statefulTestConnection()}; + app.login("sid-shared-instance"); + + BridgeHandler customer{app.bridge(), app.gui()}; + const auto acct = openChecking(app, customer); + + BridgeHandler screen{app.bridge(), app.gui()}; + BridgeHandler sidebar{app.bridge(), app.gui()}; + + // Each keyed action attaches its handler to the account it names, so both + // land on the same instance rather than on two copies of the same row. + REQUIRE(await(screen.execute(bank::dto::GetAccount{.id = acct}), app.guiLoop()).id == acct); + REQUIRE(await(sidebar.execute(bank::dto::GetAccount{.id = acct}), app.guiLoop()).id == acct); + + REQUIRE(screen.primary().value() == acct); + REQUIRE(sidebar.primary().value() == acct); + + // The directory holds exactly one entry for the account both handlers named. + REQUIRE(await(screen.instances(), app.guiLoop()) == std::vector{acct}); +} + +TEST_CASE("a cached account re-hydrates after another model moves money", "[stateful-account]") { + bank::app::App app{statefulTestConnection()}; + app.login("tess-stale-cache"); + + BridgeHandler customer{app.bridge(), app.gui()}; + const auto acct = openChecking(app, customer); + + BridgeHandler account{app.bridge(), app.gui()}; + BridgeHandler txns{app.bridge(), app.gui()}; + + REQUIRE(await(account.execute(bank::dto::GetAccount{.id = acct}), app.guiLoop()).balanceMinor == 0); + + // TransactionModel owns the atomic write and settles on its *own* SQLite + // connection, so the cached row above is now stale. The version bump is what + // makes the next read notice — without it a stateful model would happily + // serve money that no longer exists. + await(txns.execute(bank::dto::Deposit{.accountId = acct, .amountMinor = 12345}), app.guiLoop()); + + REQUIRE(await(account.execute(bank::dto::GetAccount{.id = acct}), app.guiLoop()).balanceMinor == 12345); +} + +TEST_CASE("a plain account handler keeps its own instance", "[stateful-account]") { + bank::app::App app{statefulTestConnection()}; + app.login("percy-private-instance"); + + BridgeHandler customer{app.bridge(), app.gui()}; + const auto acct = openChecking(app, customer); + + BridgeHandler shared{app.bridge(), app.gui()}; + BridgeHandler priv{app.bridge(), app.gui()}; + + REQUIRE(await(shared.execute(bank::dto::GetAccount{.id = acct}), app.guiLoop()).id == acct); + REQUIRE(await(priv.execute(bank::dto::GetAccount{.id = acct}), app.guiLoop()).id == acct); + + // Both answer correctly — they read the same row — but only the opted-in + // handler is in the directory, so the plain one is invisible to it. + REQUIRE(await(shared.instances(), app.guiLoop()).size() == 1); +} + +TEST_CASE("closing through the cached instance still enforces the zero-balance rule", "[stateful-account]") { + bank::app::App app{statefulTestConnection()}; + app.login("cass-close-guard"); + + BridgeHandler customer{app.bridge(), app.gui()}; + const auto acct = openChecking(app, customer); + + BridgeHandler account{app.bridge(), app.gui()}; + BridgeHandler txns{app.bridge(), app.gui()}; + + await(txns.execute(bank::dto::Deposit{.accountId = acct, .amountMinor = 500}), app.guiLoop()); + + // The guard reads the cached row, so it only holds because the deposit + // invalidated that cache. + auto refused = await(account.execute(bank::dto::CloseAccount{.id = acct}), app.guiLoop()); + REQUIRE_FALSE(refused.ok); + + await(txns.execute(bank::dto::Withdraw{.accountId = acct, .amountMinor = 500}), app.guiLoop()); + auto accepted = await(account.execute(bank::dto::CloseAccount{.id = acct}), app.guiLoop()); + REQUIRE(accepted.ok); + + REQUIRE(await(account.execute(bank::dto::GetAccount{.id = acct}), app.guiLoop()).status == + static_cast(bank::AccountStatus::Closed)); +} diff --git a/examples/bank/tests/test_statement.cpp b/examples/bank/tests/test_statement.cpp index 92355370..4e1a1596 100644 --- a/examples/bank/tests/test_statement.cpp +++ b/examples/bank/tests/test_statement.cpp @@ -11,7 +11,7 @@ #include "bank/dto/account_dto.hpp" #include "bank/dto/statement_dto.hpp" #include "bank/dto/transaction_dto.hpp" -#include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" #include "bank/models/statement_model.hpp" #include "bank/models/transaction_model.hpp" #include "bank_test_support.hpp" @@ -31,7 +31,7 @@ std::string testConnection() { TEST_CASE("StatementModel aggregates credits and debits across accounts", "[statement]") { bank::app::App app{testConnection()}; app.login("nina-stmt"); - morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; morph::bridge::BridgeHandler txns{app.bridge(), app.gui()}; morph::bridge::BridgeHandler statements{app.bridge(), app.gui()}; diff --git a/examples/bank/tests/test_transaction.cpp b/examples/bank/tests/test_transaction.cpp index 9c205804..b1884ebb 100644 --- a/examples/bank/tests/test_transaction.cpp +++ b/examples/bank/tests/test_transaction.cpp @@ -11,6 +11,7 @@ #include "bank/dto/account_dto.hpp" #include "bank/dto/transaction_dto.hpp" #include "bank/models/account_model.hpp" +#include "bank/models/customer_model.hpp" #include "bank/models/transaction_model.hpp" #include "bank_test_support.hpp" @@ -24,9 +25,9 @@ std::string testConnection() { } /// Opens a fresh checking account in the given currency and returns its id. -std::int64_t openAccount(bank::app::App& app, morph::bridge::BridgeHandler& accounts, +std::int64_t openAccount(bank::app::App& app, morph::bridge::BridgeHandler& customer, bank::Currency currency = bank::Currency::USD) { - auto info = await(accounts.execute(bank::dto::OpenAccount{ + auto info = await(customer.execute(bank::dto::OpenAccount{ .kind = static_cast(bank::AccountKind::Checking), .currency = static_cast(currency), .overdraftMinor = 0, @@ -41,9 +42,10 @@ TEST_CASE("TransactionModel deposit / withdraw adjust balances and ledger", "[tr bank::app::App app{testConnection()}; app.login("erin-txn"); morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler accountsOwner{app.bridge(), app.gui()}; morph::bridge::BridgeHandler txns{app.bridge(), app.gui()}; - const std::int64_t acct = openAccount(app, accounts); + const std::int64_t acct = openAccount(app, accountsOwner); SECTION("deposit increases the balance and records a credit") { auto entry = @@ -79,10 +81,11 @@ TEST_CASE("TransactionModel transfer is atomic and balance-preserving", "[transa bank::app::App app{testConnection()}; app.login("frank-transfer"); morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler accountsOwner{app.bridge(), app.gui()}; morph::bridge::BridgeHandler txns{app.bridge(), app.gui()}; - const std::int64_t src = openAccount(app, accounts); - const std::int64_t dst = openAccount(app, accounts); + const std::int64_t src = openAccount(app, accountsOwner); + const std::int64_t dst = openAccount(app, accountsOwner); await(txns.execute(bank::dto::Deposit{.accountId = src, .amountMinor = 10000}), app.guiLoop()); SECTION("a valid transfer moves money and conserves the total") { From 779bd8aa398444efb102a2082c8663fa42a72df9 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 27 Jul 2026 23:43:56 +0200 Subject: [PATCH 05/42] feat(core): instance subscriptions, replacing the reactive-draft mechanism subscribe(cb) is now keyed on the result/state type and fires whenever an R is produced on the instance the handler is attached to -- by this handler, by another handler sharing the instance, or by another screen entirely. A subscriber names what it renders, not what somebody else must call to produce it, so adding an action that also yields an R never breaks an existing subscriber. Removes the reactive-draft mechanism it supersedes: set<&A::field>, reset, the action-keyed subscribe, and the in-flight coalescing whose subtleties only existed because the draft was remote from its validator. ActionValidator survives with its A1 server-side role; it loses only its draft-readiness one. morph::flows::FlowSession is reworked onto direct dispatch. It already owned its own _drafts tuple and merely mirrored into the handler's, so it now gates on ActionValidator itself and executes the completed step. Public FlowSession API, the w-*/app-* schema, and WizardView.qml are unchanged; all flows/app tests pass untouched. Subscriptions are held against the binding, not a fixed instance id, so a re-pointed handler keeps them -- "tell me about the account I am looking at" keeps working when the user switches accounts. Co-Authored-By: Claude Opus 5 (1M context) --- include/morph/core/bridge.hpp | 334 ++++++---------- include/morph/forms/flows.hpp | 67 ++-- tests/test_computed_fields.cpp | 22 +- tests/test_coverage_extra.cpp | 24 +- tests/test_coverage_gaps.cpp | 162 +------- tests/test_example.cpp | 8 +- tests/test_subscription.cpp | 710 +++++++++++---------------------- 7 files changed, 423 insertions(+), 904 deletions(-) diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index 6e6c2a83..b52be543 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -109,9 +110,8 @@ namespace detail { /// @brief Compile-time decomposition of a pointer-to-data-member type. /// -/// Used by `BridgeHandler::set` to recover both the action -/// type and the field type from a single non-type template parameter, so -/// callers write `handler.set<&MyAction::c>(7.0)` with no redundant type +/// Recovers both the class and the member type from a single non-type template +/// parameter, so callers name a field as `&MyAction::c` with no redundant type /// arguments. /// /// @tparam T The pointer-to-member type (e.g. `double MyAction::*`). @@ -349,6 +349,80 @@ class Bridge { return loadBackend()->listInstances(std::string{::morph::model::ModelTraits::typeId()}); } + /// @brief Registers a result-type subscription for @p binding. + /// + /// The subscription is stored against the *binding*, not against a fixed + /// instance id, and is matched at publish time by comparing the binding's + /// current instance. Re-pointing a handler therefore moves its subscriptions + /// with it, which is what makes "tell me about the account I am looking at" + /// keep working when the user switches accounts. + /// + /// @param binding Handler binding that owns the subscription. + /// @param type Result type being subscribed to. + /// @param sink Type-erased delivery callback; receives the boxed result. + /// @param exec Executor the callback is delivered on. + void addSubscription(const std::shared_ptr& binding, std::type_index type, + std::function sink, ::morph::exec::IExecutor* exec) { + std::scoped_lock const lock{_subMtx}; + for (auto& entry : _subscriptions) { + auto owner = entry.binding.lock(); + if (owner && owner.get() == binding.get() && entry.type == type) { + entry.sink = std::move(sink); // one callback per (handler, result type) + entry.exec = exec; + return; + } + } + _subscriptions.push_back({.binding = binding, .type = type, .sink = std::move(sink), .exec = exec}); + } + + /// @brief Removes @p binding's subscription for @p type, if any. + /// @param binding Handler binding that owns the subscription. + /// @param type Result type to stop hearing about. + void removeSubscription(const std::shared_ptr& binding, std::type_index type) { + std::scoped_lock const lock{_subMtx}; + std::erase_if(_subscriptions, [&](const InstanceSubscription& entry) { + auto owner = entry.binding.lock(); + return !owner || (owner.get() == binding.get() && entry.type == type); + }); + } + + /// @brief Delivers @p value to every subscriber attached to instance @p mid. + /// + /// Called for every successful action result. Subscribers are matched on + /// *the instance the result was produced on*, so a handler hears about work + /// another handler — or, with a shared instance, another screen entirely — + /// did on the model it is attached to. + /// + /// The producing handler is notified too: suppressing the echo would force + /// every subscriber to special-case "was this mine", which is exactly the + /// bookkeeping the feature exists to remove. + /// + /// Sinks are snapshotted under the lock and invoked outside it, so a + /// subscriber that re-enters the bridge cannot deadlock. + /// + /// @param mid Instance the result was produced on. + /// @param type Result type produced. + /// @param value Boxed result. + void publishResult(::morph::exec::detail::ModelId mid, std::type_index type, const std::any& value) { + std::vector, ::morph::exec::IExecutor*>> targets; + { + std::scoped_lock const lock{_subMtx}; + for (const auto& entry : _subscriptions) { + auto owner = entry.binding.lock(); + if (owner && entry.type == type && owner->currentId.load() == mid.v && entry.sink) { + targets.emplace_back(entry.sink, entry.exec); + } + } + } + for (auto& [sink, exec] : targets) { + if (exec != nullptr) { + exec->post([sink, value] { sink(value); }); + } else { + sink(value); + } + } + } + /// @brief Installs a default session context that `executeVia` stamps onto the /// `ActionCall` of every subsequent call. /// @@ -541,9 +615,8 @@ class Bridge { call.localOp = [sharedAction](::morph::model::detail::IModelHolder& holder) -> std::shared_ptr { // Enforce the action's validator on the local execution path too, so // a caller that constructs an Action by hand and calls - // BridgeHandler::execute() directly — bypassing the - // reactive set<>/tryFireImpl gate that already checks ready() — is - // rejected the same way a hand-built wire envelope is rejected by + // BridgeHandler::execute() directly is rejected the + // same way a hand-built wire envelope is rejected by // ActionDispatcher::registerAction's runner (registry.hpp). No JSON is // involved on this path, so there is no declared-precision // reconciliation step here (that only applies to decoded wire @@ -596,7 +669,8 @@ class Bridge { } auto anyCompletion = backend->execute(::morph::exec::detail::ModelId{raw}, std::move(call), cbExec); anyCompletion - .then([typedState, onResult = std::move(onResult)](const std::shared_ptr& vAny) { + .then([typedState, onResult = std::move(onResult), this, raw, + alive = liveness()](const std::shared_ptr& vAny) { // Guard the value-forwarding: if R's move/copy throws (or the cast // is somehow wrong), route the exception to the typed completion's // error sink instead of letting it escape the callback executor — @@ -612,6 +686,16 @@ class Bridge { if (onResult) { onResult(*typedResult); } + // Fan the result out to everything attached to this + // instance before the value is moved away. Guarded on the + // bridge's liveness token: a completion can in principle + // resolve after the Bridge is gone. + if constexpr (std::is_copy_constructible_v) { + if (!alive.expired()) { + publishResult(::morph::exec::detail::ModelId{raw}, std::type_index{typeid(R)}, + std::any{*typedResult}); + } + } typedState->setValue(std::move(*typedResult)); } catch (...) { typedState->setException(std::current_exception()); @@ -688,6 +772,17 @@ class Bridge { std::vector> _handlers; mutable std::mutex _sessionMtx; ::morph::session::Context _defaultSession; + // Instance subscriptions. Held against the binding rather than a fixed + // instance id so a re-pointed handler keeps its subscriptions; matched at + // publish time by comparing the binding's current instance. + struct InstanceSubscription { + std::weak_ptr binding; + std::type_index type; + std::function sink; + ::morph::exec::IExecutor* exec = nullptr; + }; + std::mutex _subMtx; + std::vector _subscriptions; // Destroyed with the Bridge; handlers hold weak_ptrs to it (see liveness()). std::shared_ptr _liveness{std::make_shared()}; }; @@ -719,15 +814,13 @@ struct AllowShared {}; /// On construction, registers a `HandlerBinding` on the bridge. On destruction, /// deregisters it automatically. The handler is non-copyable. /// -/// @par Fielded actions and subscriptions -/// Beyond the one-shot `execute(action) -> Completion` API, the handler -/// offers a streaming surface for actions whose values arrive field-by-field -/// from a GUI (typically one widget per field): +/// @par Instance subscriptions +/// Beyond the one-shot `execute(action) -> Completion` API, a handler can +/// observe *the instance it is attached to*: /// -/// - `subscribe(cb)` stashes a result callback for action type `A`. -/// - `set<&A::field>(value)` updates one field of the in-progress draft of `A`. -/// - `unsubscribe()` drops the callback. -/// - `reset()` discards the in-progress draft of `A`. +/// - `subscribe(cb)` fires whenever an `R` is produced on that instance, by +/// any handler attached to it. +/// - `unsubscribe()` drops the callback. /// /// @tparam Model Concrete model type. template @@ -745,13 +838,9 @@ class BridgeHandler { : _bridge{bridge}, _bridgeAlive{bridge.liveness()}, _guiExec{guiExec}, - _binding{makeBinding(bridge)}, - _subs{std::make_shared()} { + _binding{makeBinding(bridge)} { static_assert(!kShared || ::morph::model::KeyedModel, "BridgeHandler requires Model to declare a PrimaryKey alias"); - _subs->bridge = &_bridge; - _subs->binding = _binding; - _subs->guiExec = _guiExec; } /// @brief Constructs the handler with a pre-built binding (for dependency injection). @@ -763,12 +852,8 @@ class BridgeHandler { : _bridge{bridge}, _bridgeAlive{bridge.liveness()}, _guiExec{guiExec}, - _binding{std::move(binding)}, - _subs{std::make_shared()} { + _binding{std::move(binding)} { _bridge.registerHandler(_binding); - _subs->bridge = &_bridge; - _subs->binding = _binding; - _subs->guiExec = _guiExec; } /// @brief Deregisters the binding from the bridge. @@ -928,69 +1013,34 @@ class BridgeHandler { /// @return The GUI/callback executor passed at construction. [[nodiscard]] ::morph::exec::IExecutor* guiExecutor() const noexcept { return _guiExec; } - /// @brief Subscribes to results of action type @p Action. + /// @brief Subscribes to results of type @p R produced on the attached instance. /// - /// @tparam Action Concrete action type registered with `BRIDGE_REGISTER_ACTION`. - /// @param cb Callable receiving the action's `Result` by value on the GUI executor. - template - void subscribe(std::function::Result)> cb) { - using R = ::morph::model::ActionTraits::Result; - auto wrapper = [cb = std::move(cb)](const std::any& boxed) { cb(std::any_cast(boxed)); }; - std::scoped_lock lock{_subs->mtx}; - _subs->entries[::morph::model::ActionTraits::typeId()].sink = std::move(wrapper); - } - - /// @brief Subscribes to both results and errors of action type @p Action. + /// Fires whenever an `R` is produced on the instance this handler is + /// attached to — by this handler, by another handler sharing the instance, + /// or by another screen entirely. The subscriber names *what it renders*, + /// not what somebody else must call to produce it, so adding an action that + /// also yields an `R` never breaks an existing subscriber. /// - /// @tparam Action Concrete action type registered with `BRIDGE_REGISTER_ACTION`. - /// @param cb Result callback invoked on success. - /// @param errCb Error callback invoked on failure (replaces orphan logging). - template - void subscribe(std::function::Result)> cb, - std::function errCb) { - subscribe(std::move(cb)); - std::scoped_lock lock{_subs->mtx}; - _subs->entries[::morph::model::ActionTraits::typeId()].errSink = std::move(errCb); + /// One callback per `(handler, R)`: subscribing again replaces the previous + /// one. Callbacks are delivered on this handler\'s executor. Failed actions + /// notify nobody; delivery is best-effort and unbuffered, with no replay. + /// + /// @tparam R Result/state type to observe. + /// @param cb Callable receiving the value by value on the GUI executor. + template + void subscribe(std::function cb) { + _bridge.addSubscription( + _binding, std::type_index{typeid(R)}, + [cb = std::move(cb)](const std::any& boxed) { cb(std::any_cast(boxed)); }, _guiExec); } - /// @brief Removes the subscriber for action type @p Action. - template + /// @brief Removes this handler\'s subscription for @p R. + /// @tparam R Result/state type to stop hearing about. + template void unsubscribe() { - std::scoped_lock lock{_subs->mtx}; - auto iter = _subs->entries.find(::morph::model::ActionTraits::typeId()); - if (iter != _subs->entries.end()) { - iter->second.sink = nullptr; - iter->second.errSink = nullptr; - } - } - - /// @brief Sets one field of the in-progress draft and fires the action if ready. - /// - /// @tparam FieldPtr Pointer-to-data-member of the action struct (encodes both action and field type). - /// @param value New value for the field. - template - void set(detail::MemberPointerTraits::ValueType value) { - using A = detail::MemberPointerTraits::ClassType; - { - std::scoped_lock lock{_subs->mtx}; - auto& entry = _subs->entries[::morph::model::ActionTraits::typeId()]; - if (!entry.draft.has_value()) { - entry.draft = A{}; - } - std::any_cast(entry.draft).*FieldPtr = std::move(value); - } - tryFireImpl(_subs, ::morph::model::ActionTraits::typeId()); + _bridge.removeSubscription(_binding, std::type_index{typeid(R)}); } - /// @brief Discards the in-progress draft for action @p Action. - template - void reset() { - std::scoped_lock lock{_subs->mtx}; - auto iter = _subs->entries.find(::morph::model::ActionTraits::typeId()); - if (iter != _subs->entries.end()) { - iter->second.draft.reset(); - } - } /// @brief Returns the underlying `HandlerBinding`. /// @@ -998,132 +1048,10 @@ class BridgeHandler { [[nodiscard]] const std::shared_ptr& binding() const { return _binding; } private: - struct SubscriberEntry { - std::any draft; - std::function sink; - std::function errSink; - bool running{false}; - bool pending{false}; - }; - struct SubscriberState { - std::mutex mtx; - Bridge* bridge{nullptr}; - std::shared_ptr binding; - ::morph::exec::IExecutor* guiExec{nullptr}; - /// @note Keys are `std::string_view` pointing to string literals from - /// `ActionTraits::typeId()` — all call sites pass compile-time strings - /// with static storage duration, so the map's keys never dangle. - std::unordered_map entries; - }; - - struct PostExec { - std::function sink; - std::function errSink; - bool refire{false}; - }; - - static PostExec consumeFlight(SubscriberState& state, std::string_view typeId) { - PostExec out; - std::scoped_lock lock{state.mtx}; - auto& entry = state.entries.find(typeId)->second; - out.sink = entry.sink; - out.errSink = entry.errSink; - entry.running = false; - if (entry.pending) { - entry.pending = false; - out.refire = true; - } - return out; - } - - static void logUnhandledError(std::string_view typeId, const std::exception_ptr& err) { - try { - std::rethrow_exception(err); - } catch (const std::exception& exc) { - ::morph::log::logError(std::string{"[subscription:"} + std::string{typeId} + - "] unhandled exception: " + exc.what()); - } catch (...) { - ::morph::log::logError(std::string{"[subscription:"} + std::string{typeId} + - "] unhandled unknown exception"); - } - } - - template - static void tryFireImpl(const std::shared_ptr& state, std::string_view typeId) { - // Every caller holds the `SubscriberState` alive for the duration of this - // call, so no liveness check is needed on entry. The async continuations - // below may outlive the subscription, so they each re-check through this - // weak_ptr instead. - const std::weak_ptr weak{state}; - using R = ::morph::model::ActionTraits::Result; - - Action snapshot; - { - std::scoped_lock lock{state->mtx}; - auto iter = state->entries.find(typeId); - if (iter == state->entries.end() || !iter->second.draft.has_value()) { - return; - } - if (iter->second.running) { - iter->second.pending = true; - return; - } - snapshot = std::any_cast(iter->second.draft); - // Recompute every declared computed field live, on the snapshot, - // before the readiness check and fire -- so a validator that - // inspects a computed field sees the freshly-derived value, and - // the fired action already carries it. No-op for actions with no - // computedFields. This is a live, non-authoritative recompute for - // display; the dispatch paths (bridge.hpp's ActionExecuteRegistry - // executor and localOp, registry.hpp's ActionDispatcher runner) - // recompute it again, authoritatively. See docs/spec/forms/forms.md. - ::morph::forms::recomputeAll(snapshot); - } - if (!::morph::model::ActionValidator::ready(snapshot)) { - return; - } - { - std::scoped_lock lock{state->mtx}; - state->entries[typeId].running = true; - } - - state->bridge->template executeVia(state->binding, std::move(snapshot), state->guiExec) - .then([weak, typeId](R result) { - auto inner = weak.lock(); - if (!inner) { - return; - } - auto outcome = consumeFlight(*inner, typeId); - if (outcome.sink) { - std::any boxed{std::move(result)}; - outcome.sink(boxed); - } - if (outcome.refire) { - tryFireImpl(inner, typeId); - } - }) - .onError([weak, typeId](const std::exception_ptr& err) { - auto inner = weak.lock(); - if (!inner) { - return; - } - auto outcome = consumeFlight(*inner, typeId); - if (outcome.errSink) { - outcome.errSink(err); - } else { - logUnhandledError(typeId, err); - } - if (outcome.refire) { - tryFireImpl(inner, typeId); - } - }); - } - Bridge& _bridge; std::weak_ptr _bridgeAlive; // expires when _bridge is destroyed ::morph::exec::IExecutor* _guiExec; std::shared_ptr _binding; - std::shared_ptr _subs; }; /// Out-of-line definition of ActionExecuteRegistry::registerAction. diff --git a/include/morph/forms/flows.hpp b/include/morph/forms/flows.hpp index 05e54711..42660667 100644 --- a/include/morph/forms/flows.hpp +++ b/include/morph/forms/flows.hpp @@ -208,7 +208,7 @@ class FlowSession { explicit FlowSession(::morph::bridge::BridgeHandler& handler, std::function onError = nullptr) : _handler{handler}, _onError{std::move(onError)} { - subscribeCurrent(); + beginStep(); } /// @brief Flags this session as gone, then unsubscribes the current step. @@ -222,7 +222,6 @@ class FlowSession { /// touching a partially- or fully-destroyed object. ~FlowSession() { _alive->store(false, std::memory_order_release); - unsubscribeCurrent(); } FlowSession(const FlowSession&) = delete; @@ -244,11 +243,22 @@ class FlowSession { if (::morph::model::ActionTraits::typeId() != currentActionType()) { throw std::logic_error{"FlowSession::set<>: field belongs to an action that is not the current step"}; } + A draft{}; + std::size_t stepIndex = 0; { std::scoped_lock const lock{_mtx}; - std::get(_drafts).*FieldPtr = value; + std::get(_drafts).*FieldPtr = std::move(value); + draft = std::get(_drafts); + stepIndex = _activeStep; + } + // The readiness gate that used to live in the handler's draft machinery + // lives here now: the flow already owns the draft, so it can decide when + // the step is complete and dispatch it itself. Note there is no + // in-flight coalescing — each ready `set<>` dispatches, where the old + // handler-side draft collapsed patches landing during a flight. + if (::morph::model::ActionValidator::ready(draft)) { + fireStep(std::move(draft), stepIndex); } - _handler.template set(std::move(value)); } /// @brief Moves to the next step, if the current step has already produced @@ -264,7 +274,6 @@ class FlowSession { if (!ready || finished()) { return false; } - unsubscribeCurrent(); ++_index; { std::scoped_lock const lock{_mtx}; @@ -276,7 +285,7 @@ class FlowSession { _activeStep = _index; } if (!finished()) { - subscribeCurrent(); + beginStep(); } return true; } @@ -289,14 +298,13 @@ class FlowSession { if (_index == 0) { return false; } - unsubscribeCurrent(); --_index; { std::scoped_lock const lock{_mtx}; _currentReady = true; // this step already produced a result once, or it could not have been left _activeStep = _index; } - subscribeCurrent(); + beginStep(); return true; } @@ -374,23 +382,25 @@ class FlowSession { _currentReady = true; } - /// @brief Installs the result/error sinks for step @p A. + /// @brief Dispatches step @p A's completed draft and routes its outcome. /// /// Both closures capture `_alive` (a copy of the `shared_ptr`, so it - /// outlives `this` if the two race) and check it before touching - /// anything on `this` — see `~FlowSession()`'s doc comment for why - /// `unsubscribe()` alone is not enough. + /// outlives `this` if the two race) and check it before touching anything on + /// `this` — a completion can still resolve after the flow is destroyed. + /// @tparam A Step action type. + /// @param draft The completed action to execute. + /// @param stepIndex Index of the step this dispatch belongs to. template - void installSubscription(std::size_t stepIndex) { + void fireStep(A draft, std::size_t stepIndex) { auto alive = _alive; - _handler.template subscribe( - [this, alive, stepIndex](::morph::model::ActionTraits::Result result) { + _handler.execute(std::move(draft)) + .then([this, alive, stepIndex](::morph::model::ActionTraits::Result result) { if (!alive->load(std::memory_order_acquire)) { return; } this->template captureResult(result, stepIndex); - }, - [this, alive, stepIndex](std::exception_ptr err) { + }) + .onError([this, alive, stepIndex](const std::exception_ptr& err) { if (!alive->load(std::memory_order_acquire)) { return; } @@ -425,22 +435,13 @@ class FlowSession { } } - void subscribeCurrent() { - // Publish which step the callbacks about to be installed belong to. - // Read back under the same mutex by every callback, so one that fires - // after the flow has moved on can recognise itself as stale. - std::size_t stepIndex = 0; - { - std::scoped_lock const lock{_mtx}; - _activeStep = _index; - stepIndex = _index; - } - detail::forStep(_index, - [this, stepIndex] { this->template installSubscription(stepIndex); }); - } - - void unsubscribeCurrent() { - detail::forStep(_index, [this] { _handler.template unsubscribe(); }); + /// @brief Publishes which step is now current. + /// + /// Read back under the same mutex by every dispatch callback, so one that + /// resolves after the flow has moved on recognises itself as stale. + void beginStep() { + std::scoped_lock const lock{_mtx}; + _activeStep = _index; } ::morph::bridge::BridgeHandler& _handler; diff --git a/tests/test_computed_fields.cpp b/tests/test_computed_fields.cpp index b07dcf4e..962ad9ee 100644 --- a/tests/test_computed_fields.cpp +++ b/tests/test_computed_fields.cpp @@ -240,26 +240,36 @@ TEST_CASE("BridgeHandler::set<> recomputes total live before firing", "[bridge][ } }); - handler.set<&CFLineItem::qty>(Rational{Numerator{3}, Denominator{1}, dp2}); - handler.set<&CFLineItem::price>(Rational{Numerator{2}, Denominator{1}, dp2}); + // `total` is left unengaged on purpose: recomputeAll overwrites it from + // qty*price on the dispatch path, which is what this asserts. + handler.execute(CFLineItem{.qty = Rational{Numerator{3}, Denominator{1}, dp2}, + .price = Rational{Numerator{2}, Denominator{1}, dp2}, + .total = {}}); REQUIRE(morph::testing::waitUntil([&] { return haveTotal.load(); })); std::scoped_lock lock{totalMtx}; CHECK(lastTotal == Rational{6, dp2}); } -TEST_CASE("BridgeHandler::set<> does not fire before both computed inputs are engaged", "[bridge][computed]") { +TEST_CASE("an action with a computed input missing fails its validator", "[bridge][computed]") { morph::exec::ThreadPoolExecutor pool{2}; SyncExecutor cbExec; morph::bridge::Bridge bridge{std::make_unique(pool)}; morph::bridge::BridgeHandler handler{bridge, &cbExec}; std::atomic fired{false}; + std::atomic failed{false}; handler.subscribe([&](CFLineItem /*unused*/) { fired.store(true); }); - handler.set<&CFLineItem::qty>(Rational{Numerator{3}, Denominator{1}, dp2}); - std::this_thread::sleep_for(std::chrono::milliseconds{50}); - CHECK_FALSE(fired.load()); // price still missing -> total unengaged -> validate() is false + // price is missing, so total stays unengaged and validate() is false. The + // validator gate now sits on the dispatch path rather than in a client-side + // draft, so the action is rejected instead of simply never firing -- and a + // failed action notifies no subscriber. + handler.execute(CFLineItem{.qty = Rational{Numerator{3}, Denominator{1}, dp2}, .price = {}, .total = {}}) + .onError([&](const std::exception_ptr&) { failed.store(true); }); + + REQUIRE(morph::testing::waitUntil([&] { return failed.load(); })); + CHECK_FALSE(fired.load()); } // --------------------------------------------------------------------------- diff --git a/tests/test_coverage_extra.cpp b/tests/test_coverage_extra.cpp index 35cfbe9d..ffffed64 100644 --- a/tests/test_coverage_extra.cpp +++ b/tests/test_coverage_extra.cpp @@ -52,29 +52,17 @@ TEST_CASE("BridgeHandler::unsubscribe on type with no entry is a no-op", "[bridg morph::bridge::Bridge bridge{std::make_unique(pool)}; morph::bridge::BridgeHandler handler{bridge, &cb}; - // No prior subscribe → the unsubscribe path finds no entry and exercises - // the False arm of `if (iter != _subs->entries.end())` at line 311. - REQUIRE_NOTHROW(handler.unsubscribe()); + // No prior subscribe, so the removal path finds nothing to erase. + REQUIRE_NOTHROW(handler.unsubscribe()); } -TEST_CASE("BridgeHandler::reset on type with no entry is a no-op", "[bridge]") { +TEST_CASE("BridgeHandler::unsubscribe is idempotent", "[bridge]") { morph::exec::ThreadPoolExecutor pool{1}; CovSyncExecutor cb; morph::bridge::Bridge bridge{std::make_unique(pool)}; morph::bridge::BridgeHandler handler{bridge, &cb}; - REQUIRE_NOTHROW(handler.reset()); -} - -TEST_CASE("BridgeHandler::set on a field reuses an existing draft", "[bridge]") { - morph::exec::ThreadPoolExecutor pool{1}; - CovSyncExecutor cb; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cb}; - - // First set creates the draft entry → line 327 True arm. Second set finds - // the entry already there → False arm. - handler.set<&CovAction::v>(1); - handler.set<&CovAction::v>(2); - REQUIRE(true); + handler.subscribe([](int) {}); + REQUIRE_NOTHROW(handler.unsubscribe()); + REQUIRE_NOTHROW(handler.unsubscribe()); } diff --git a/tests/test_coverage_gaps.cpp b/tests/test_coverage_gaps.cpp index a7836200..dd8d4943 100644 --- a/tests/test_coverage_gaps.cpp +++ b/tests/test_coverage_gaps.cpp @@ -6,7 +6,9 @@ #include #include +#include #include +#include #include #include #include @@ -423,116 +425,26 @@ BRIDGE_REGISTER_VALIDATOR(SlowSubAction, [](const SlowSubAction& act) { return a BRIDGE_REGISTER_VALIDATOR(ThrowSubAction, [](const ThrowSubAction& act) { return act.trigger != 0; }) BRIDGE_REGISTER_VALIDATOR(WeirdSubAction, [](const WeirdSubAction& act) { return act.trigger != 0; }) -TEST_CASE("morph::bridge::BridgeHandler: handler destroyed mid-flight makes weak-lock continuations no-op", - "[coverage][bridge]") { - // Covers tryFireImpl's outer weak.lock() check (489-490) and the - // then-continuation's inner weak.lock() (518-519). The action sleeps long - // enough for us to destroy the handler before the continuation runs. - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - - { - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - std::atomic got{-1}; - handler.subscribe([&](int value) { got.store(value); }); - handler.set<&SlowSubAction::seq>(7); - // Don't wait — drop the handler immediately so the continuation lands - // after SubscriberState is destroyed. - } - // Let the pool drain so the dispatched op finishes (no-op via weak). - std::this_thread::sleep_for(120ms); - REQUIRE(true); -} - -TEST_CASE("morph::bridge::BridgeHandler: onError continuation no-ops when SubscriberState already gone", - "[coverage][bridge]") { - // Covers the onError-continuation's weak.lock() failure path (532-533). - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - - { - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - handler.subscribe([](int) {}); // sink installed but action throws below - // Use ThrowSubAction which throws synchronously inside the strand; the - // onError continuation lands after the handler dies. - handler.subscribe([](int) {}); - handler.set<&ThrowSubAction::trigger>(1); - } - std::this_thread::sleep_for(60ms); - REQUIRE(true); -} - -// ── bridge.hpp: logUnhandledError non-std::exception branch (lines 480-482) - -TEST_CASE("morph::bridge::BridgeHandler: logUnhandledError covers non-std::exception branch", "[coverage][bridge]") { - // No errSink installed → outcome.errSink is empty → onError continuation - // calls logUnhandledError, which rethrows; the action's exception is a - // non-std type, so the catch(...) arm fires. - morph::exec::ThreadPoolExecutor pool{2}; +TEST_CASE("morph::bridge::BridgeHandler: unsubscribe with no entry is a no-op", "[coverage][bridge]") { + morph::exec::ThreadPoolExecutor pool{1}; SyncExecutor cbExec; morph::bridge::Bridge bridge{std::make_unique(pool)}; morph::bridge::BridgeHandler handler{bridge, &cbExec}; - LogGuard guard; - std::atomic sawUnknown{false}; - morph::log::setLogger([&](morph::log::LogLevel /*lvl*/, std::string_view msg) { - if (msg.contains("unknown")) { - sawUnknown.store(true); - } - }); - - handler.subscribe([](int) {}); // sink only, no errSink - handler.set<&WeirdSubAction::trigger>(1); - - REQUIRE(waitFor([&] { return sawUnknown.load(); })); + REQUIRE_NOTHROW(handler.unsubscribe()); } -TEST_CASE("morph::bridge::BridgeHandler: logUnhandledError covers std::exception branch", "[coverage][bridge]") { - // Same shape but with a std::exception payload; covers lines 476-479 by - // making sure the catch(const std::exception&) arm runs in addition to - // the catch(...) arm above. - morph::exec::ThreadPoolExecutor pool{2}; +TEST_CASE("morph::bridge::Bridge: publishResult with no subscribers is a no-op", "[coverage][bridge]") { + morph::exec::ThreadPoolExecutor pool{1}; SyncExecutor cbExec; morph::bridge::Bridge bridge{std::make_unique(pool)}; morph::bridge::BridgeHandler handler{bridge, &cbExec}; - LogGuard guard; - std::atomic sawStd{false}; - morph::log::setLogger([&](morph::log::LogLevel /*lvl*/, std::string_view msg) { - if (msg.contains("threw inside action")) { - sawStd.store(true); - } - }); - - handler.subscribe([](int) {}); // sink only, no errSink - handler.set<&ThrowSubAction::trigger>(1); - - REQUIRE(waitFor([&] { return sawStd.load(); })); + // Nothing is subscribed, so the fan-out loop finds no matching entry. + REQUIRE_NOTHROW(bridge.publishResult(::morph::exec::detail::ModelId{1}, std::type_index{typeid(int)}, + std::any{int{0}})); } -// ── bridge.hpp: refire-after-error path (lines 541-542) - -// ── bridge.hpp: unsubscribe/reset early-return branches (lines 374, 416) - -TEST_CASE("morph::bridge::BridgeHandler: unsubscribe with no entry is a no-op", "[coverage][bridge]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - REQUIRE_NOTHROW(handler.unsubscribe()); // entries empty → false arm at 374 -} - -TEST_CASE("morph::bridge::BridgeHandler: reset with no entry is a no-op", "[coverage][bridge]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - REQUIRE_NOTHROW(handler.reset()); // false arm at 416 -} - -// ── bridge.hpp: deregisterHandler search lambda's mismatch arm (line 163) TEST_CASE("morph::bridge::Bridge: deregisterHandler skips other bindings", "[coverage][bridge]") { // With two live handlers, deregistering one walks past the other in the @@ -663,34 +575,6 @@ TEST_CASE("morph::bridge::Bridge: switchBackend purges weak_ptr bindings whose o // ── bridge.hpp: tryFireImpl returns when draft is absent (lines 498-499) -TEST_CASE("morph::bridge::BridgeHandler: tryFireImpl bails when draft has been reset", "[coverage][bridge]") { - // After reset<>(), the entry's draft is empty. A subsequent tryFireImpl - // would observe `!iter->second.draft.has_value()` and return. - // We can't call tryFireImpl directly (private), but we can drive a - // refire path: set fires → in flight → reset clears draft → continuation - // returns refire=true → tryFireImpl re-enters → entry exists but draft - // empty → 498-499. - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - std::atomic sinkCount{0}; - handler.subscribe([&](int) { sinkCount.fetch_add(1); }); - - // First set fires SlowSubAction (sleeps 40ms in the model). - handler.set<&SlowSubAction::seq>(7); - // Pile a second set on while the first is still in flight — pending=true. - handler.set<&SlowSubAction::seq>(8); - // Drop the draft. When the first dispatch's continuation lands, refire - // is queued and the recursive tryFireImpl sees an empty draft → 498-499. - handler.reset(); - - // Let everything settle. Either path is fine — we just need the recursive - // tryFireImpl to be invoked with the empty draft. - std::this_thread::sleep_for(120ms); -} - // ── model.hpp: morph::model::detail::IModelHolder::into() throws std::bad_cast (lines 70-71) TEST_CASE("morph::model::detail::IModelHolder::into() throws std::bad_cast", "[coverage][model]") { @@ -699,29 +583,3 @@ TEST_CASE("morph::model::detail::IModelHolder::into() throws std::bad_cas // sanity: into() still works REQUIRE_NOTHROW(holder->template into()); } - -TEST_CASE("morph::bridge::BridgeHandler: refire fires again after a failed action", "[coverage][bridge]") { - // Burst two set<>s on a throwing action: the first kicks off the dispatch, - // the second sets pending=true while running. On error, consumeFlight - // returns refire=true → tryFireImpl is re-invoked from the onError arm. - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - std::atomic errorsSeen{0}; - handler.subscribe([](int) {}, [&](const std::exception_ptr&) { errorsSeen.fetch_add(1); }); - - // First trigger — dispatch starts, may already be in flight. - handler.set<&ThrowSubAction::trigger>(1); - // Second trigger while the first is most likely still in flight on the strand; - // marks pending=true → onError consumeFlight returns refire=true → fires - // tryFireImpl again, which dispatches a second time → second error. - handler.set<&ThrowSubAction::trigger>(2); - - // We may or may not race the in-flight window; at minimum we expect one - // error (no refire) or two errors (refire happened). Wait for the refire - // path with a longer budget; if we never see two, that's fine — the test - // still exercised consumeFlight. - REQUIRE(waitFor([&] { return errorsSeen.load() >= 1; })); -} diff --git a/tests/test_example.cpp b/tests/test_example.cpp index ee2b1cce..289217fe 100644 --- a/tests/test_example.cpp +++ b/tests/test_example.cpp @@ -40,14 +40,14 @@ TEST_CASE("Example Model", "[model]") { .then([&](ActionOutput output) { REQUIRE(output.result == 6.0); }) .onError([](const std::exception_ptr&) { FAIL("Action execution should not have thrown an exception"); }); + // Subscribing names the *result* type, so the observer never has to know + // which action produced it. std::atomic fired{false}; - handler.subscribe([&](ActionOutput output) { + handler.subscribe([&](ActionOutput output) { REQUIRE(output.result == 6.0); fired.store(true); }); - handler.set<&ActionInput::a>(1.0); - handler.set<&ActionInput::b>(2.0); - handler.set<&ActionInput::c>(3.0); + handler.execute(ActionInput{1.0, 2.0, 3.0}); std::this_thread::sleep_for(std::chrono::milliseconds{50}); REQUIRE(fired.load() == true); diff --git a/tests/test_subscription.cpp b/tests/test_subscription.cpp index 577738a5..0e64115e 100644 --- a/tests/test_subscription.cpp +++ b/tests/test_subscription.cpp @@ -1,573 +1,307 @@ // SPDX-License-Identifier: Apache-2.0 +// +// Tests for instance subscriptions (F3). +// +// `subscribe(cb)` is keyed on the **result/state type** and fires whenever an +// `R` is produced on the instance the handler is attached to — by this handler, +// by another handler sharing the instance, or by another screen entirely. It +// replaces the reactive-draft mechanism (`set<&A::field>`, `reset`, and an +// action-keyed `subscribe`), whose job a stateful model does better by holding +// the draft itself; see docs/planned/instance_subscriptions.md. +// +// The subscriber names *what it renders*, not what somebody else must call to +// produce it, so adding an action that also yields an `R` never breaks an +// existing subscriber. +#include +#include +#include +#include #include #include -#include #include #include -#include -#include -#include -#include #include #include -#include -#include #include "test_support.hpp" +// ── Fixture: a stateful counter, so a subscription reports real shared state ── -// ── Test fixture: a model with two action types ───────────────────────────── -// -// FormAction takes five doubles; the validator requires all three of {a, b, c} -// to be non-zero before the action may fire. d and e are optional inputs. -struct FormAction { - double a = 0.0; - double b = 0.0; - double c = 0.0; - double d = 0.0; - double e = 0.0; -}; - -// SimpleAction has no validator override — default morph::model::ActionValidator returns true, -// so it should fire on the first set<>. -struct SimpleAction { - int x = 0; +/// The state type subscribers name. Produced by more than one action, which is +/// exactly the case result-keyed subscription exists to serve. +struct SubCounterState { + std::int64_t value = 0; }; -struct ThrowAction { - int trigger = 0; +/// A second, unrelated state type — used to prove types do not cross-talk. +struct SubLabelState { + std::string text; }; -// FlakyAction throws only when `mode == 0`, otherwise returns mode * 2. Used to test -// re-fire after error: first fire throws, second fire (after setting mode=1) succeeds. -struct FlakyAction { - int mode = -1; +struct SubBump { + std::int64_t id = 0; + std::int64_t by = 0; }; -// A bundle of non-numeric field types to verify set<> with strings and nested structs. -struct Inner { - int n = 0; -}; -struct MixedAction { - std::string name; - Inner inner; - int count = 0; +struct SubRead { + std::int64_t id = 0; }; -// A slow action whose `execute` sleeps long enough that bursting set<>() during -// the in-flight call exercises the coalescing path explicitly. -struct SlowAction { - int seq = 0; +struct SubLabel { + std::int64_t id = 0; }; -struct FormModel { - double execute(FormAction action) { return action.a + action.b + action.c + action.d + action.e; } - int execute(SimpleAction action) { return action.x * 10; } - int execute(ThrowAction /*unused*/) { throw std::runtime_error("boom"); } - int execute(FlakyAction action) { - if (action.mode == 0) { - throw std::runtime_error("flaky"); - } - return action.mode * 2; - } - std::string execute(const MixedAction& action) { - return action.name + ":" + std::to_string(action.inner.n) + ":" + std::to_string(action.count); - } - int execute(SlowAction action) { - std::this_thread::sleep_for(std::chrono::milliseconds{40}); - return action.seq; - } +struct SubExplode { + std::int64_t id = 0; }; -BRIDGE_REGISTER_MODEL(FormModel, "Test_FormModel") -BRIDGE_REGISTER_ACTION(FormModel, FormAction, "Test_FormAction") -BRIDGE_REGISTER_ACTION(FormModel, SimpleAction, "Test_SimpleAction") -BRIDGE_REGISTER_ACTION(FormModel, ThrowAction, "Test_ThrowAction") -BRIDGE_REGISTER_ACTION(FormModel, FlakyAction, "Test_FlakyAction") -BRIDGE_REGISTER_ACTION(FormModel, MixedAction, "Test_MixedAction") -BRIDGE_REGISTER_ACTION(FormModel, SlowAction, "Test_SlowAction") - -BRIDGE_REGISTER_VALIDATOR(FormAction, [](const FormAction& a) { - return a.a != 0.0 && a.b != 0.0 && a.c != 0.0; -}) -BRIDGE_REGISTER_VALIDATOR(ThrowAction, [](const ThrowAction& a) { return a.trigger != 0; }) -BRIDGE_REGISTER_VALIDATOR(FlakyAction, [](const FlakyAction& a) { return a.mode >= 0; }) -BRIDGE_REGISTER_VALIDATOR(MixedAction, [](const MixedAction& a) { - return !a.name.empty() && a.inner.n != 0 && a.count != 0; -}) - -using SyncExecutor = morph::testing::InlineExecutor; +struct SubCounterModel { + using PrimaryKey = std::int64_t; -namespace { + std::int64_t value = 0; -template -void waitFor(Pred pred, std::chrono::milliseconds budget = std::chrono::milliseconds{2000}) { - auto deadline = std::chrono::steady_clock::now() + budget; - while (!pred() && std::chrono::steady_clock::now() < deadline) { - std::this_thread::sleep_for(std::chrono::milliseconds{2}); + SubCounterState execute(const SubBump& act) { + value += act.by; + return {.value = value}; } -} + SubCounterState execute(const SubRead& /*act*/) const { return {.value = value}; } + SubLabelState execute(const SubLabel& /*act*/) const { return {.text = "label"}; } + static SubCounterState execute(const SubExplode& /*act*/) { throw std::runtime_error{"boom"}; } +}; -} // namespace +BRIDGE_REGISTER_MODEL(SubCounterModel, "SUB_CounterModel") +BRIDGE_REGISTER_ACTION(SubCounterModel, SubBump, "SUB_Bump") +BRIDGE_REGISTER_ACTION(SubCounterModel, SubRead, "SUB_Read") +BRIDGE_REGISTER_ACTION(SubCounterModel, SubLabel, "SUB_Label") +BRIDGE_REGISTER_ACTION(SubCounterModel, SubExplode, "SUB_Explode") -TEST_CASE("Subscription: default validator fires on first set", "[bridge][subscription]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; +BRIDGE_KEY_FROM(SubBump, &SubBump::id); +BRIDGE_KEY_FROM(SubRead, &SubRead::id); +BRIDGE_KEY_FROM(SubLabel, &SubLabel::id); +BRIDGE_KEY_FROM(SubExplode, &SubExplode::id); - std::atomic seen{-1}; - handler.subscribe([&](int result) { seen.store(result); }); +namespace { - handler.set<&SimpleAction::x>(7); +using morph::bridge::AllowShared; +using morph::bridge::Bridge; +using morph::bridge::BridgeHandler; - waitFor([&] { return seen.load() != -1; }); - REQUIRE(seen.load() == 70); +/// Runs a completion to resolution, ignoring its value. +template +void drain(morph::async::Completion comp) { + auto done = std::make_shared>(false); + std::move(comp).then([done](T) { done->store(true); }).onError([done](const std::exception_ptr&) { + done->store(true); + }); + REQUIRE(morph::testing::waitUntil([&] { return done->load(); })); } -TEST_CASE("Subscription: custom validator gates fire until ready", "[bridge][subscription]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - std::atomic calls{0}; - std::atomic last{0.0}; - handler.subscribe([&](double sum) { - last.store(sum); - calls.fetch_add(1); - }); +std::unique_ptr makeLocal(morph::exec::IExecutor& pool) { + return std::make_unique(pool); +} - // a alone is not ready → no fire - handler.set<&FormAction::a>(1.0); - std::this_thread::sleep_for(std::chrono::milliseconds{30}); - REQUIRE(calls.load() == 0); +} // namespace - // a + b still not enough → no fire - handler.set<&FormAction::b>(2.0); - std::this_thread::sleep_for(std::chrono::milliseconds{30}); - REQUIRE(calls.load() == 0); +TEST_CASE("a subscriber hears results produced by its own handler", "[bridge][subscription]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + BridgeHandler handler{bridge, &exec}; - // a + b + c → validator passes, action fires - handler.set<&FormAction::c>(4.0); + std::int64_t seen = 0; + int fires = 0; + handler.subscribe([&](SubCounterState state) { + seen = state.value; + ++fires; + }); - waitFor([&] { return calls.load() >= 1; }); - REQUIRE(calls.load() == 1); - REQUIRE(last.load() == 7.0); + drain(handler.execute(SubBump{.id = 1, .by = 7})); + REQUIRE(fires == 1); + REQUIRE(seen == 7); } -TEST_CASE("Subscription: re-fires on subsequent sets after ready", "[bridge][subscription]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - std::mutex resultsMtx; - std::vector results; - handler.subscribe([&](double sum) { - std::scoped_lock lock{resultsMtx}; - results.push_back(sum); - }); - - // Cross readiness on the third set - handler.set<&FormAction::a>(1.0); - handler.set<&FormAction::b>(1.0); - handler.set<&FormAction::c>(1.0); // first fire = 3.0 - waitFor([&] { - std::scoped_lock lock{resultsMtx}; - return !results.empty(); - }); +TEST_CASE("a subscriber hears another handler's work on the shared instance", "[bridge][subscription]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; - // Now the draft already passes validation; every subsequent set re-fires - // once the in-flight call settles. Push more values and observe results - // converge to the latest snapshot. - handler.set<&FormAction::d>(10.0); - handler.set<&FormAction::e>(100.0); + BridgeHandler watcher{bridge, &exec}; + BridgeHandler actor{bridge, &exec}; - waitFor([&] { - std::scoped_lock lock{resultsMtx}; - return !results.empty() && results.back() == 113.0; - }); + watcher.attach(10); + std::int64_t seen = -1; + watcher.subscribe([&](SubCounterState state) { seen = state.value; }); - std::scoped_lock lock{resultsMtx}; - REQUIRE(!results.empty()); - REQUIRE(results.front() == 3.0); - REQUIRE(results.back() == 113.0); - // Coalescing means we expect fewer fires than sets: at most one per - // "round", not one per set. - REQUIRE(results.size() <= 3); + // A different handler, on the same instance: the watcher does not need to + // know that SubBump exists, only that SubCounterState is what it renders. + drain(actor.execute(SubBump{.id = 10, .by = 3})); + REQUIRE(seen == 3); } -TEST_CASE("Subscription: error sink receives model exceptions", "[bridge][subscription]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - std::atomic errFired{false}; - handler.subscribe( - [](int /*unused*/) {}, - [&](const std::exception_ptr& err) { - try { - std::rethrow_exception(err); - } catch (const std::runtime_error&) { - errFired.store(true); - } - }); - - handler.set<&ThrowAction::trigger>(1); - - waitFor([&] { return errFired.load(); }); - REQUIRE(errFired.load()); -} +TEST_CASE("a subscriber hears nothing from a different instance", "[bridge][subscription]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; -TEST_CASE("Subscription: unsubscribe stops further results", "[bridge][subscription]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; + BridgeHandler watcher{bridge, &exec}; + BridgeHandler elsewhere{bridge, &exec}; - std::atomic calls{0}; - handler.subscribe([&](int /*unused*/) { calls.fetch_add(1); }); + watcher.attach(20); + bool fired = false; + watcher.subscribe([&](SubCounterState) { fired = true; }); - handler.set<&SimpleAction::x>(1); - waitFor([&] { return calls.load() >= 1; }); - REQUIRE(calls.load() == 1); + drain(elsewhere.execute(SubBump{.id = 21, .by = 1})); + REQUIRE_FALSE(fired); +} - handler.unsubscribe(); +TEST_CASE("a subscription follows its handler when it re-points", "[bridge][subscription]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; - handler.set<&SimpleAction::x>(2); - // give the worker a chance to fire (action still executes; the result is - // dropped because no sink). The count should stay at 1. - std::this_thread::sleep_for(std::chrono::milliseconds{50}); - REQUIRE(calls.load() == 1); -} + BridgeHandler watcher{bridge, &exec}; + BridgeHandler actor{bridge, &exec}; -TEST_CASE("Subscription: distinct action types do not interfere", "[bridge][subscription]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - std::atomic simple{-1}; - std::atomic form{-1.0}; - handler.subscribe([&](int result) { simple.store(result); }); - handler.subscribe([&](double sum) { form.store(sum); }); - - handler.set<&SimpleAction::x>(3); - handler.set<&FormAction::a>(1.0); - handler.set<&FormAction::b>(2.0); - handler.set<&FormAction::c>(3.0); // FormAction now ready - - waitFor([&] { return simple.load() != -1 && form.load() != -1.0; }); - REQUIRE(simple.load() == 30); - REQUIRE(form.load() == 6.0); -} + watcher.attach(30); + std::int64_t seen = -1; + watcher.subscribe([&](SubCounterState state) { seen = state.value; }); -TEST_CASE("Subscription: reset clears the draft", "[bridge][subscription]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - std::atomic last{-1.0}; - handler.subscribe([&](double sum) { last.store(sum); }); - - handler.set<&FormAction::a>(1.0); - handler.set<&FormAction::b>(2.0); - handler.set<&FormAction::c>(3.0); - waitFor([&] { return last.load() == 6.0; }); - - handler.reset(); - - // After reset, just setting `a` alone shouldn't fire — validator needs all of a/b/c. - last.store(-1.0); - handler.set<&FormAction::a>(5.0); - std::this_thread::sleep_for(std::chrono::milliseconds{50}); - REQUIRE(last.load() == -1.0); - - // Re-fill and confirm the draft genuinely restarted from defaults. - handler.set<&FormAction::b>(5.0); - handler.set<&FormAction::c>(5.0); - waitFor([&] { return last.load() != -1.0; }); - REQUIRE(last.load() == 15.0); -} + // "Tell me about the account I am looking at" must keep working when the + // user switches accounts, so the subscription moves with the handler. + watcher.attach(31); + drain(actor.execute(SubBump{.id = 31, .by = 5})); + REQUIRE(seen == 5); -TEST_CASE("Subscription: result is dropped silently with no sink installed", "[bridge][subscription]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - // No subscribe() — set<> still triggers execute, but result drops. - handler.set<&SimpleAction::x>(42); - std::this_thread::sleep_for(std::chrono::milliseconds{50}); - // No assertion required; the test passes if nothing crashes and no orphan - // error is logged (because there is no exception, just a discarded result). - SUCCEED("no-sink fire completed without UAF or hang"); + seen = -1; + drain(actor.execute(SubBump{.id = 30, .by = 9})); + REQUIRE(seen == -1); // the instance it left behind is no longer its business } -TEST_CASE("Subscription: callbacks no-op after handler destruction", "[bridge][subscription]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; +TEST_CASE("distinct result types do not interfere", "[bridge][subscription]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + BridgeHandler handler{bridge, &exec}; + handler.attach(40); - std::atomic calls{0}; - { - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - handler.subscribe([&](int /*unused*/) { calls.fetch_add(1); }); - handler.set<&SimpleAction::x>(1); - waitFor([&] { return calls.load() >= 1; }); - } - // Handler has been destroyed. Give any racing completion callbacks a window - // to fire — they should now see a null weak_ptr and no-op. - std::this_thread::sleep_for(std::chrono::milliseconds{50}); - REQUIRE(calls.load() == 1); -} + int counters = 0; + int labels = 0; + handler.subscribe([&](SubCounterState) { ++counters; }); + handler.subscribe([&](SubLabelState) { ++labels; }); -TEST_CASE("Subscription: second subscribe replaces the first", "[bridge][subscription]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - std::atomic firstCalls{0}; - std::atomic secondCalls{0}; - handler.subscribe([&](int /*unused*/) { firstCalls.fetch_add(1); }); - handler.subscribe([&](int /*unused*/) { secondCalls.fetch_add(1); }); - - handler.set<&SimpleAction::x>(1); - waitFor([&] { return secondCalls.load() >= 1; }); - // Only the latest subscriber sees results. - REQUIRE(secondCalls.load() == 1); - REQUIRE(firstCalls.load() == 0); -} + drain(handler.execute(SubBump{.id = 40, .by = 1})); + REQUIRE(counters == 1); + REQUIRE(labels == 0); -TEST_CASE("Subscription: set before subscribe drops the result", "[bridge][subscription]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - // Fire before any subscriber is installed — result is computed and dropped. - handler.set<&SimpleAction::x>(1); - std::this_thread::sleep_for(std::chrono::milliseconds{30}); - - // Now install a subscriber. It must not see the prior dropped result. - std::atomic calls{0}; - std::atomic last{-1}; - handler.subscribe([&](int val) { - last.store(val); - calls.fetch_add(1); - }); - std::this_thread::sleep_for(std::chrono::milliseconds{30}); - REQUIRE(calls.load() == 0); - - // The next set still fires from the preserved draft (x already 1 from earlier), - // but at this point the value is whatever the next set lands. - handler.set<&SimpleAction::x>(5); - waitFor([&] { return calls.load() >= 1; }); - REQUIRE(calls.load() == 1); - REQUIRE(last.load() == 50); + drain(handler.execute(SubLabel{.id = 40})); + REQUIRE(counters == 1); + REQUIRE(labels == 1); } -TEST_CASE("Subscription: works with string and nested-struct fields", "[bridge][subscription]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - std::atomic fired{false}; - std::string seen; - std::mutex seenMtx; - handler.subscribe([&](std::string result) { - std::scoped_lock lock{seenMtx}; - seen = std::move(result); - fired.store(true); - }); +TEST_CASE("every action producing the type notifies the subscriber", "[bridge][subscription]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + BridgeHandler handler{bridge, &exec}; + handler.attach(50); - handler.set<&MixedAction::name>(std::string{"alpha"}); - handler.set<&MixedAction::inner>(Inner{42}); - handler.set<&MixedAction::count>(7); + int fires = 0; + handler.subscribe([&](SubCounterState) { ++fires; }); - waitFor([&] { return fired.load(); }); - std::scoped_lock lock{seenMtx}; - REQUIRE(seen == "alpha:42:7"); + drain(handler.execute(SubBump{.id = 50, .by = 1})); + drain(handler.execute(SubRead{.id = 50})); // a different action, same state type + REQUIRE(fires == 2); } -TEST_CASE("Subscription: unsubscribe preserves the draft", "[bridge][subscription]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - std::atomic last{-1.0}; - handler.subscribe([&](double sum) { last.store(sum); }); - - handler.set<&FormAction::a>(1.0); - handler.set<&FormAction::b>(2.0); - handler.set<&FormAction::c>(3.0); - waitFor([&] { return last.load() == 6.0; }); - - handler.unsubscribe(); - // The draft is still intact. Mutate one more field; the action will fire - // again (no subscriber → result drops) but the draft state persists. - handler.set<&FormAction::d>(10.0); - std::this_thread::sleep_for(std::chrono::milliseconds{30}); - - // Re-subscribe; the next set must fire against the *preserved* draft state - // (a/b/c/d already populated), producing a + b + c + d + e = 16 + e. - last.store(-1.0); - handler.subscribe([&](double sum) { last.store(sum); }); - handler.set<&FormAction::e>(100.0); - waitFor([&] { return last.load() != -1.0; }); - REQUIRE(last.load() == 116.0); -} +TEST_CASE("subscribing again replaces the previous callback", "[bridge][subscription]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + BridgeHandler handler{bridge, &exec}; + handler.attach(60); -TEST_CASE("Subscription: works under morph::backend::SimulatedRemoteBackend", "[bridge][subscription][remote]") { - morph::exec::ThreadPoolExecutor serverPool{2}; - auto server = std::make_shared(serverPool); - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(*server)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; + int first = 0; + int second = 0; + handler.subscribe([&](SubCounterState) { ++first; }); + handler.subscribe([&](SubCounterState) { ++second; }); - std::atomic last{-1.0}; - handler.subscribe([&](double sum) { last.store(sum); }); + drain(handler.execute(SubBump{.id = 60, .by = 1})); + REQUIRE(first == 0); + REQUIRE(second == 1); +} - handler.set<&FormAction::a>(2.0); - handler.set<&FormAction::b>(3.0); - handler.set<&FormAction::c>(5.0); +TEST_CASE("unsubscribe stops further delivery", "[bridge][subscription]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + BridgeHandler handler{bridge, &exec}; + handler.attach(70); - waitFor([&] { return last.load() == 10.0; }, std::chrono::milliseconds{4000}); - REQUIRE(last.load() == 10.0); -} + int fires = 0; + handler.subscribe([&](SubCounterState) { ++fires; }); + drain(handler.execute(SubBump{.id = 70, .by = 1})); + REQUIRE(fires == 1); -TEST_CASE("Subscription: draft survives switchBackend", "[bridge][subscription][switch]") { - morph::exec::ThreadPoolExecutor pool1{2}; - morph::exec::ThreadPoolExecutor pool2{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool1)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - std::atomic last{-1.0}; - handler.subscribe([&](double sum) { last.store(sum); }); - - // Build a partial draft — validator does not pass yet, so nothing fires. - handler.set<&FormAction::a>(1.0); - handler.set<&FormAction::b>(2.0); - std::this_thread::sleep_for(std::chrono::milliseconds{30}); - REQUIRE(last.load() == -1.0); - - // Switch backends mid-edit. The draft lives in the handler, so it survives. - bridge.switchBackend(std::make_unique(pool2)); - - // Complete the draft. The fire must reach the new backend. - handler.set<&FormAction::c>(3.0); - waitFor([&] { return last.load() != -1.0; }); - REQUIRE(last.load() == 6.0); + handler.unsubscribe(); + drain(handler.execute(SubBump{.id = 70, .by = 1})); + REQUIRE(fires == 1); } -TEST_CASE("Subscription: re-fires after a failed execute", "[bridge][subscription]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - std::atomic okFired{0}; - std::atomic errFired{0}; - std::atomic lastOk{-1}; - handler.subscribe( - [&](int val) { - lastOk.store(val); - okFired.fetch_add(1); - }, - [&](const std::exception_ptr& /*unused*/) { errFired.fetch_add(1); }); - - // mode=0 → execute throws → errFired increments, running flag must reset. - handler.set<&FlakyAction::mode>(0); - waitFor([&] { return errFired.load() >= 1; }); - REQUIRE(errFired.load() == 1); - REQUIRE(okFired.load() == 0); - - // mode=5 → execute succeeds → okFired increments. If the previous error - // path failed to clear `running`, this set would silently be queued as - // `pending` and never actually run. - handler.set<&FlakyAction::mode>(5); - waitFor([&] { return okFired.load() >= 1; }); - REQUIRE(okFired.load() == 1); - REQUIRE(lastOk.load() == 10); - REQUIRE(errFired.load() == 1); -} +TEST_CASE("a failed action notifies nobody", "[bridge][subscription]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + BridgeHandler handler{bridge, &exec}; + handler.attach(80); -TEST_CASE("Subscription: unhandled errors route to the framework logger", "[bridge][subscription]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - // Save and restore the global logger around the test so we don't pollute - // other tests with our spy. - auto savedLevel = morph::log::getLogLevel(); - morph::log::setLogLevel(morph::log::LogLevel::debug); - std::mutex captureMtx; - std::string captured; - morph::log::setLogger([&](morph::log::LogLevel /*lvl*/, std::string_view msg) { - std::scoped_lock lock{captureMtx}; - captured.append(msg).append("\n"); - }); + bool fired = false; + handler.subscribe([&](SubCounterState) { fired = true; }); - // Subscribe with success-only (no errCb). The action throws — without our - // fallback, the error would be silently swallowed because the framework's - // internal .onError attachment suppresses CompletionState's orphan log. - handler.subscribe([](int /*unused*/) {}); - handler.set<&ThrowAction::trigger>(1); + drain(handler.execute(SubExplode{.id = 80})); + REQUIRE_FALSE(fired); +} - waitFor([&] { - std::scoped_lock lock{captureMtx}; - return captured.contains("[subscription:"); - }); +TEST_CASE("delivery stops once the subscribing handler is destroyed", "[bridge][subscription]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + BridgeHandler actor{bridge, &exec}; + actor.attach(90); + int fires = 0; { - std::scoped_lock lock{captureMtx}; - REQUIRE(captured.contains("[subscription:Test_ThrowAction]")); - REQUIRE(captured.contains("boom")); + BridgeHandler watcher{bridge, &exec}; + watcher.attach(90); + watcher.subscribe([&](SubCounterState) { ++fires; }); + drain(actor.execute(SubBump{.id = 90, .by = 1})); + REQUIRE(fires == 1); } - morph::log::setLogger(nullptr); - morph::log::setLogLevel(savedLevel); + drain(actor.execute(SubBump{.id = 90, .by = 1})); + REQUIRE(fires == 1); } -TEST_CASE("Subscription: bursts coalesce while a fire is in flight", "[bridge][subscription]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - std::atomic fires{0}; - std::atomic lastSeen{-1}; - handler.subscribe([&](int val) { - lastSeen.store(val); - fires.fetch_add(1); - }); +TEST_CASE("a private handler's results stay private", "[bridge][subscription]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; - // First set kicks off a fire that will sleep ~40ms inside execute. - handler.set<&SlowAction::seq>(1); + BridgeHandler watcher{bridge, &exec}; + BridgeHandler priv{bridge, &exec}; - // Burst many sets while that fire is in flight. With one-running + - // one-pending coalescing, the in-flight call completes, then a single - // re-fire runs with the latest snapshot (seq=10). - for (int idx = 2; idx <= 10; ++idx) { - handler.set<&SlowAction::seq>(idx); - } + watcher.attach(100); + bool fired = false; + watcher.subscribe([&](SubCounterState) { fired = true; }); + + // The plain handler has its own instance, so nothing it does is on the + // instance the watcher is attached to. + drain(priv.execute(SubBump{.id = 100, .by = 1})); + REQUIRE_FALSE(fired); +} + +TEST_CASE("instance subscriptions work under SimulatedRemoteBackend", "[bridge][subscription][remote]") { + morph::testing::InlineExecutor exec; + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool); + Bridge bridge{std::make_unique(*server)}; + + BridgeHandler watcher{bridge, &exec}; + BridgeHandler actor{bridge, &exec}; + watcher.attach(110); - waitFor([&] { return lastSeen.load() == 10; }, std::chrono::milliseconds{4000}); + auto seen = std::make_shared>(-1); + watcher.subscribe([seen](SubCounterState state) { seen->store(state.value); }); - REQUIRE(lastSeen.load() == 10); - // 10 set<>() calls; without coalescing we'd see up to 10 fires. The - // invariant is "strictly fewer fires than sets, and the last fire used - // the latest snapshot". In practice this is typically 2 (the first + - // one coalesced re-fire), but we test the invariant, not the exact count. - REQUIRE(fires.load() >= 1); - REQUIRE(fires.load() < 10); + drain(actor.execute(SubBump{.id = 110, .by = 4})); + REQUIRE(morph::testing::waitUntil([&] { return seen->load() == 4; })); } From 0cf74dde7382a78243114b7f8f0506bc22decec2 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 27 Jul 2026 23:48:32 +0200 Subject: [PATCH 06/42] =?UTF-8?q?docs:=20fold=20the=20shipped=20=C2=A7F=20?= =?UTF-8?q?design=20into=20the=20authoritative=20specs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spec/core/shared_instances.md: the keyed/shared-instance design, moved out of docs/planned and rewritten present-tense now that it ships. - spec/core/bridge.md: "Subscription semantics" rewritten for result-keyed instance subscriptions; SubscriberState, set<>, reset<> and tryFireImpl removed along with the mechanism they described. - ARCHITECTURE.md: "Subscriptions and fielded actions" becomes "Instance subscriptions", with the new behaviour table. - forms/workflows_navigation.md, forms/forms.md: FlowSession dispatches directly, so recomputeAll now has three call sites and all are authoritative -- the client-side display-only one is gone. - examples/bank/README.md: documents the stateful, keyed models and the cross-model staleness edge. - README.md: corrects three "Status & limitations" claims that §A/§B had already invalidated, and states the in-process scope of subscriptions. docs/planned/ is empty again, as its own convention requires. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 25 +- docs/ARCHITECTURE.md | 67 ++-- docs/planned/instance_subscriptions.md | 314 ----------------- docs/planned/stateful_bank_example.md | 326 ------------------ docs/spec/core/bridge.md | 170 ++++----- .../core/shared_instances.md} | 49 ++- docs/spec/forms/forms.md | 12 +- docs/spec/forms/workflows_navigation.md | 20 +- .../2026-07-06-reactive-forms-bridge.md | 8 +- docs/todo.md | 55 ++- examples/bank/README.md | 4 +- 11 files changed, 191 insertions(+), 859 deletions(-) delete mode 100644 docs/planned/instance_subscriptions.md delete mode 100644 docs/planned/stateful_bank_example.md rename docs/{planned/shared_model_instances.md => spec/core/shared_instances.md} (90%) diff --git a/README.md b/README.md index f5f4162c..4cd37162 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ and the call site above are unchanged. `Bridge` and share it. - **`BridgeHandler`** — your typed, GUI-facing handle to one model type `M`. It registers `M` on the bridge on construction and deregisters on destruction - (RAII). This is the object you call `.execute(...)` / `.subscribe(...)` on. + (RAII). This is the object you call `.execute(...)` / `.subscribe(...)` on. Create one per model type, wherever in the UI you need to talk to that model. ## Multiple models across multiple files @@ -382,14 +382,19 @@ morph is a young, actively developed library with thorough test coverage of its core. It is honest about the following boundaries — read the per-subsystem specs in [`docs/spec/`](docs/spec) before relying on any of these in production: -- **Security is app-supplied.** The wire protocol has no version negotiation, - no message-size or timeout bounds, and no built-in authentication; `Context` - identity is unauthenticated and `RemoteServer` model ids are guessable - sequential integers with control messages unauthorized. `RemoteServer` - assumes a trusted, authenticated transport — it is not a hardened - public-internet server as shipped. +- **Security is app-supplied.** There is no built-in authentication: `Context` + identity is whatever the client claims until an `IAuthorizer` verifies it, and + the default authorizer allows everything. Protocol version negotiation, + message-size and timeout bounds, opaque model ids, and register/per-instance + authorization hooks all ship (see `docs/spec/security.md`), but they are + **opt-in** — a server that configures none of them assumes a trusted, + authenticated transport and is not a hardened public-internet server. - **`Completion` is a leaf callback primitive**, not a composable future: one handler per outcome, no `T→U` chaining, no `co_await`, no cancellation. +- **Instance subscriptions are best-effort and in-process.** `subscribe` + fans out to handlers on the same `Bridge`; there is no server-initiated push, + so two separate clients sharing an instance do not see each other's results + until they ask again. No replay, no durability, no coalescing. - **Exact numbers are fixed-width.** `Rational` is an `int64` pair; `+`/`-`/`*` can overflow (undefined behaviour) rather than returning an error, and high decimal precision shrinks the representable magnitude. Wire input is *clamped*, @@ -399,8 +404,10 @@ in [`docs/spec/`](docs/spec) before relying on any of these in production: the store/log divergence gap by opting into `IModelHolder::setOutboxManaged` + `journal::OutboxRelay` (see `docs/spec/journal/journal.md`); a model that doesn't opt in keeps the default fire-after-success append. -- **Offline durability is bring-your-own.** Only an in-memory queue ships; the - crash-safety story depends on a durable queue you implement. +- **Offline durability is opt-in.** `FileOfflineQueue` (NDJSON, always built) + and `SqliteOfflineQueue` (`MORPH_BUILD_OFFLINE_SQLITE`) both persist across + restarts; the in-memory queue remains the default, so crash-safety depends on + selecting a durable one. - **Registration is global and macro-driven** (per-TU, static-init, string type ids); there is no runtime deregistration and unknown ids fail at runtime, not compile time. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 557e37a5..24b52b8a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -310,7 +310,7 @@ Because these are mutually exclusive per topology, recording is automatically se **`Loggable`** (`morph::model::Loggable::{Yes,No}`) is a strong-typed opt-out on the existing `BRIDGE_REGISTER_ACTION` macro (an optional 4th argument; no separate registration macro). Default is `Yes` — every action is recorded unless explicitly marked `Loggable::No` (typically pure queries like `GetAccount`/`ListAccounts`). Hand-written `ActionTraits` specialisations that predate this member (as used in several tests) are unaffected: `morph::model::detail::actionLoggable()` defaults to `Yes` when the member is absent, via a `HasLoggableFlag` concept exactly like `ActionValidator`'s `HasValidate`. -**`ActionLogPolicy::coalesce`** (default `false`) decides whether repeated executions of the same action against the same entity should collapse to the latest occurrence at a checkpoint, or whether every occurrence is a distinct, permanent fact. This matters because the fielded/reactive `set<...>` mechanism (see "Subscriptions and fielded actions" below) can already fire the same action many times in a row — without coalescing, every keystroke-driven re-fire would become a permanent log entry. `false` is correct for anything resembling a business event (a deposit); `true` is for drafts/settings where only the final value matters. +**`ActionLogPolicy::coalesce`** (default `false`) decides whether repeated executions of the same action against the same entity should collapse to the latest occurrence at a checkpoint, or whether every occurrence is a distinct, permanent fact. This matters because a form driving one action per edit can fire the same action many times in a row — without coalescing, every keystroke-driven re-fire would become a permanent log entry. `false` is correct for anything resembling a business event (a deposit); `true` is for drafts/settings where only the final value matters. **`SessionLog`** (`journal.hpp`) is where coalescing actually happens. It keeps full, uncoalesced history in memory (the raw material for `undoLast()`), and `checkpoint(durableSink)` reduces everything appended since the last checkpoint by `(modelType, entityKey, actionType)` — keeping only the latest entry where `coalesce == true`, every entry otherwise — before forwarding the reduced set to the real sink. `undoLast()` needs no inverse operations: it drops the most recent entry and calls `journal::replay()` over what remains, reusing the same `ActionDispatcher`/`ModelRegistryFactory` `RemoteServer` already relies on for dispatch. This is not a workaround — a model's entire state genuinely is "initial state plus its ordered actions replayed," so reconstructing it by replay is the direct statement of that fact, not a special case. @@ -412,52 +412,51 @@ handler.execute(MyAction{21}) .onError([](std::exception_ptr e) { /* runs on GUI thread */ }); ``` -## Subscriptions and fielded actions +## Instance subscriptions -The framework offers two complementary surfaces for invoking actions from the GUI: +The framework offers two complementary surfaces for talking to a model: -1. **One-shot**: `handler.execute(action) → Completion`. The full action is built in the GUI and sent in a single call. Suitable for actions that fire on a button click ("delete this order", "submit form"). -2. **Fielded / reactive**: `handler.subscribe(cb)` + `handler.set<&A::field>(value)`. Field values stream into a client-side draft, a per-action validator decides when the draft is ready, and the framework dispatches `model.execute(draft)` and pushes the result to the subscriber. Suitable for forms where each widget edits one field and the GUI should respond live as the user types. +1. **One-shot**: `handler.execute(action) -> Completion`. The action is built + at the call site and dispatched in one call. +2. **Observing**: `handler.subscribe(cb)` fires whenever an `R` is produced + on *the instance this handler is attached to* — by this handler, by another + handler sharing that instance, or by another screen entirely. Suitable for a + view that renders some model state and must stay current when anything + changes it. -### API - -```cpp -// Per-action validator — template specialisation, no Model coupling. -template -struct morph::model::ActionValidator { - static bool ready(const A&) noexcept { return true; } // default: one-shot -}; +A subscription names the **result/state type**, not an action. The subscriber +describes what it renders rather than what somebody else must call to produce +it, so adding an action that also yields an `R` never breaks an existing +subscriber. -// User specialises (or uses the macro) for actions with fielded readiness. -BRIDGE_REGISTER_VALIDATOR(FormAction, [](const FormAction& a) { - return a.a != 0.0 && a.b != 0.0 && a.c != 0.0; -}) -``` +### API ```cpp -morph::bridge::BridgeHandler handler{bridge, &guiExec}; +morph::bridge::BridgeHandler screen{bridge, guiExec}; +morph::bridge::BridgeHandler sidebar{bridge, guiExec}; -handler.subscribe([](double sum) { renderTotal(sum); }); +screen.attach(42); +sidebar.attach(42); // same instance -handler.set<&FormAction::a>(3.0); -handler.set<&FormAction::b>(5.0); -handler.set<&FormAction::c>(7.0); // validator passes → execute → callback fires +screen.subscribe([](AccountInfo info) { renderBalance(info); }); -handler.unsubscribe(); -handler.reset(); +sidebar.execute(Deposit{.amountMinor = 5000}); +// -> screen's callback runs: it never had to know Deposit exists ``` ### Behavior | Aspect | Default | |---|---| -| **Validator default** | `ActionValidator::ready` returns `true` for any action without a specialisation. First `set<>` triggers a fire. | -| **Re-fire** | Every `set<>` that lands a `ready()==true` state dispatches the action again — live recomputation. The draft persists between fires. | -| **Draft persistence** | Drafts survive successive fires, `unsubscribe`, and `Bridge::switchBackend`. Destroyed with the handler or via `reset()`. | -| **In-flight coalescing** | If patches land while a previous execute is in flight, exactly one re-fire is queued for when it completes — running with the latest draft snapshot. Further patches during the same flight collapse into that single pending re-fire. Matches typical reactive-UI behaviour. | -| **Subscriber cardinality** | One subscriber per `(handler, Action type)`. `subscribe(cb)` replaces any prior callback. | -| **No-subscriber fire** | If `set<>` triggers an execute but no subscriber is installed, the action still runs and the result is silently dropped. | -| **Subscription thread** | Callbacks always run on the executor passed at handler construction (`guiExec`). | +| **Keying** | On the result type `R`. Any action producing an `R` notifies. | +| **Scope** | The instance the handler is currently attached to. Matched at publish time, so a subscription follows a re-pointed handler. | +| **Subscriber cardinality** | One callback per `(handler, R)`. `subscribe(cb)` replaces any prior callback. | +| **Echo** | The originating handler is notified too — no "was this mine" bookkeeping in subscribers. | +| **Failures** | A failed action notifies nobody. | +| **Ordering** | Per instance, guaranteed by that instance's strand. Nothing is guaranteed between instances. | +| **Durability** | None. Best-effort and unbuffered: no replay, no cursor, no coalescing. | +| **Callback thread** | Always the executor passed at handler construction. | + ## Exact values, units, and schema-driven forms @@ -546,7 +545,7 @@ required empty-capable field is engaged" — `Quantity`, `Choice`, `Timestamp`, or any type with a `hasValue()`) intended as the body of the action's `validate()` — which the existing `ActionValidator` resolution picks up automatically. One declaration then drives the schema's `required` array, the -client-side submit gate, and the fielded-action readiness check. +client-side submit gate, and the server-side readiness check. ### `morph::time::Timestamp` and `morph::forms::Choice` — dates and combo boxes @@ -703,7 +702,7 @@ documented behavior. | Glaze for JSON | Reflects aggregate types automatically; no hand-written serialisation per action. | | `CompletionState` internal only | Keeps the public API free of state-handling machinery; implementation can change without breaking callers. | | JSON `Envelope` wire protocol | Self-describing and forward-compatible (unknown keys ignored); carries a `callId` so async WebSocket replies can be correlated back to pending `Completion` objects. | -| Client-side drafts for fielded actions | Avoids new wire messages, server-side draft state, and a server push channel. Patches never leave the client; only the full action is sent when the validator passes. | +| Subscriptions keyed on the result type, scoped to the instance | A subscriber is a renderer: it names the state it draws, not the actions that produce it, so a new action never breaks it. Scoping to the instance is what lets two screens on one shared model see each other's work without a query-invalidation vocabulary. | | `ActionValidator` is action-typed, not model-typed | Different actions on the same model have different readiness requirements; pinning the predicate to the action keeps GUI code oblivious to model internals. | | `set(value)` over `set(&Action::f, value)` | Member-pointer NTTP encodes both the action type and the field type; the call site stays terse without losing type safety. | | `Rational` wire codec canonicalises on read | Wire input is untrusted; every deserialised value passes the reducing constructor, so invariants hold no matter what a client sends. | diff --git a/docs/planned/instance_subscriptions.md b/docs/planned/instance_subscriptions.md deleted file mode 100644 index a5496904..00000000 --- a/docs/planned/instance_subscriptions.md +++ /dev/null @@ -1,314 +0,0 @@ -# Instance subscriptions — planned - -**Status:** planned, not implemented. This document is a design proposal, not a -description of current behaviour. The authoritative present-tense specs are in -[`docs/spec/`](../spec). - -This item **removes** the reactive-draft mechanism described in -[bridge.md](../spec/core/bridge.md) and -[ARCHITECTURE.md](../ARCHITECTURE.md) ("Subscriptions and fielded actions") and -replaces it. See [What this removes](#what-this-removes) for the blast radius. - -## Contents - -- [The gap this closes](#the-gap-this-closes) -- [The new meaning of `subscribe`](#the-new-meaning-of-subscribe) -- [Why keyed on the result type](#why-keyed-on-the-result-type) -- [Delivery semantics](#delivery-semantics) -- [Wire protocol changes](#wire-protocol-changes) -- [What this removes](#what-this-removes) -- [Rebuilding reactive forms on the new primitive](#rebuilding-reactive-forms-on-the-new-primitive) -- [Reworking `morph::flows`](#reworking-morphflows) -- [API reference](#api-reference) -- [Design decisions](#design-decisions) -- [Failure modes](#failure-modes) -- [Limitations](#limitations) -- [Cross-references](#cross-references) - -## The gap this closes - -Once instances are shared ([shared_model_instances.md](shared_model_instances.md)), -two handlers — possibly in two different client processes — operate on the same -stateful model. Nothing tells either of them that the other changed it. - -morph has no server-initiated message at all. The `Envelope` protocol is -strictly request/reply, and `views.md` records "no live/push list updates" as a -non-goal. The only tool that *sounds* like a subscription, -`BridgeHandler::subscribe()`, is a client-side draft mechanism: it streams -field values into a local draft, fires when `ActionValidator::ready` passes, -and hands the caller that action's result. It never hears about anything anyone -else did. - -So a shared instance is currently a shared secret: `a1` and `a2` both hold -account 42, `a2` deposits, and `a1` shows a stale balance until something makes -it ask again. - -## The new meaning of `subscribe` - -A subscription is keyed on the **result / state type**, and fires whenever a -value of that type is produced on the instance the handler is attached to — by -*any* handler attached to it, on any connection. - -```cpp -BridgeHandler a1{bridge, gui}; -BridgeHandler a2{bridge, gui}; -a1.attach(42); -a2.attach(42); - -a1.subscribe([](AccountInfo a) { - showBalance(a.balanceMinor); // fires for a2's work too -}); - -a2.execute(Deposit{.amountMinor = 5000}); // produces an AccountInfo -// → a1's callback runs, on a1's own gui executor -``` - -The subscriber names *what it wants to see*, not *what someone else must do to -produce it*. `a1` does not need to know that `Deposit` exists, that `a2` exists, -or that a deposit is what changed the balance — only that an `AccountInfo` is -the shape of the state it renders. - -## Why keyed on the result type - -Keying on the action type was the other candidate, and it is what the current -draft mechanism does. It is the wrong choice here for three reasons: - -- **A subscriber is a renderer, not a caller.** A GUI panel showing a balance - cares about `AccountInfo`. Requiring it to enumerate every action that might - produce one (`Deposit`, `Withdraw`, `GetAccount`, `CloseAccount`, and every - action added later) makes every new action a breaking change for every - subscriber. -- **It composes with stateful models.** A keyed model's actions are mostly - keyless mutations of one state; the state type is the stable, meaningful - identity in that design, and the action set is the volatile part. -- **It is the shape the existing schema layer already assumes.** - [views.md](../spec/forms/views.md) derives its columns from the query action's - *row type*, and `AccountInfo` is described there as "the Account model's - primary result type". The result type is already the thing morph's own - generation layer treats as the model's public shape. - -## Delivery semantics - -- **Scope is the instance.** A subscription is bound to the instance the handler - is attached to at the time it fires, not to the model *type*. Re-pointing the - handler ([shared_model_instances.md](shared_model_instances.md#re-pointing-not-re-keying)) - moves its subscriptions to the new instance. -- **A handler with no primary receives only its own results.** Nothing else is - attached, so there is nothing else to hear. -- **Callbacks run on the handler's executor**, exactly as `.then` does today. - Two handlers in one process with different executors each get their callback - where they asked for it. -- **The originating handler is notified too.** `a2` executing `Deposit` gets its - ordinary `Completion` result *and*, if subscribed, its subscription callback. - Suppressing the echo would force every subscriber to special-case "was this - mine", which is exactly the bookkeeping the feature exists to remove. -- **Ordering is per instance.** Because every action on an instance runs on that - instance's strand, notifications are naturally ordered and that order is - guaranteed. No ordering is guaranteed *between* instances. -- **Delivery is best-effort and unbuffered.** A notification produced while a - client is disconnected is lost. There is no replay, no cursor, no - checkpointing. On reconnect a client re-reads state the ordinary way; the - subscription resumes from then on. This is deliberate — see - [Limitations](#limitations). -- **Failed actions notify nobody.** A notification is produced from a successful - result only. -- **One callback per `(handler, result type)`.** Subscribing again replaces the - previous callback, matching the current cardinality rule. - -## Wire protocol changes - -This introduces **the first server-initiated message in morph**. Until now every -frame a client receives is a reply to something it sent, and both transports, -the reconnect logic, and the fuzz harness assume it. That assumption ends here, -and every one of those places needs revisiting. - -- **A new `subscribe` / `unsubscribe` request pair**, carrying a model id and a - result type id. The server records `(modelId, resultTypeId) → set`. -- **A new `notify` server-initiated message**, carrying the model id, the result - type id, and the result payload. It has no `callId`, because it answers - nothing. -- **Client dispatch must gain an unsolicited-message path.** `QtWebSocketBackend` - and `morph::net`'s `SocketBackend` both currently correlate every inbound - frame to a pending call; an uncorrelated frame is presently an error and must - become a routed notification. - -Subscriptions are connection-scoped and die with the connection, so -`closeConnection` drops them alongside its instance references. They are gated -by `authorize` for the model type — a principal that may not execute against a -model may not subscribe to its results either, or the subscription becomes a -read channel that bypasses authorization. - -## What this removes - -The reactive-draft mechanism is deleted, not deprecated. Removed API: - -| Removed | What it did | -|---|---| -| `subscribe(cb)` *(old meaning)* | Registered a result callback for action `A`'s draft | -| `set<&A::field>(value)` | Streamed one field into the client-side draft | -| `unsubscribe()` *(old meaning)* | Dropped the draft's callback | -| `reset()` | Destroyed the draft | -| in-flight coalescing | Collapsed patches landing during a flight into one re-fire | -| draft persistence across `switchBackend` | Kept drafts alive over a backend swap | - -`subscribe` keeps its name with new semantics. The break is loud rather than -silent: the callback's parameter changes from *the action's result* to *the -subscribed type itself*, so existing call sites fail to compile rather than -quietly changing behaviour. - -`ActionValidator::ready` **survives**. Its original purpose was gating a -draft fire, but A1 made it the server-side validation hook enforced in the -dispatcher runner and in `Bridge::executeVia`'s `localOp` -([registry.md](../spec/core/registry.md)). It keeps that role and loses the -draft one. Its documentation must be rewritten accordingly — the phrase -"decides whether a partially-built action draft is ready to execute" becomes -wrong. - -**Blast radius** (from the current tree): - -- `include/morph/core/bridge.hpp` — the draft storage and `set<>`/`reset<>` path. -- `include/morph/forms/flows.hpp` — `FlowSession` is built directly on - `subscribe` / `unsubscribe`. See below. -- `tests/test_subscription.cpp` (~69 uses), `tests/test_coverage_gaps.cpp` - (~16), `tests/test_computed_fields.cpp`, `tests/test_coverage_extra.cpp`, - `tests/test_flows_apps.cpp`, `tests/test_example.cpp`, - `examples/bank/tests/test_payee.cpp`. -- `src/qt/forms/` — `DynamicForm.qml`'s reactive path and - `tst_DynamicFormReactive.qml`. -- Docs: `ARCHITECTURE.md`'s "Subscriptions and fielded actions", - `spec/core/bridge.md`, `spec/forms/workflows_navigation.md`, - `docs/superpowers/2026-07-06-reactive-forms-bridge.md`, and - `examples/bank/README.md`. - -morph is `0.1.0` and [VERSIONING.md](../spec/VERSIONING.md) reserves exactly -this latitude before 1.0. The removal should still land as one reviewable -change with its replacement, not as a bare deletion. - -## Rebuilding reactive forms on the new primitive - -The draft mechanism solved a real problem: a form where each widget edits one -field and the UI responds live. Dropping it is only defensible because stateful -models solve the same problem better — by putting the draft **in the model** -instead of in the client. - -Before, the draft lived on the client and fired a whole action when a validator -said it was complete: - -```cpp -handler.subscribe([](Density d) { show(d); }); -handler.set<&ComputeDensity::mass>(m); -handler.set<&ComputeDensity::volume>(v); // validator passes → fires -``` - -After, the draft is model state, each edit is an ordinary action, and the UI -subscribes to the state type: - -```cpp -handler.subscribe([](DraftState s) { show(s); }); -handler.execute(SetMass{.value = m}); -handler.execute(SetVolume{.value = v}); // model recomputes, emits DraftState -``` - -This is more round trips, and that is the honest cost. What it buys: the draft -survives a client restart, two clients editing the same draft see each other, -readiness is decided by the model that owns the rules rather than by a -client-side predicate, and there is one execution path instead of two. It also -removes the in-flight coalescing machinery, whose subtleties exist only because -the draft was remote from its validator. - -Forms whose draft genuinely is client-local — a throwaway dialog — should build -the action normally and call `execute` once. That was always the simpler path -and is now the only one. - -## Reworking `morph::flows` - -`FlowSession` drives each wizard step through -`subscribe` / `unsubscribe` on the step's action type, and is a shipped -feature (E-G8) with its own spec and QML renderer. - -Re-expressed on the new primitive, a wizard becomes a **stateful model keyed by -flow instance**: steps are actions against it, the accumulated draft is its -state, and `WizardView.qml` subscribes to that state type instead of to each -step action. This is a better fit than the current design — it gives wizards -resumability and cross-client visibility for free, and removes -`FlowSession`'s per-step subscribe/unsubscribe churn. - -It is also a substantial rewrite of a shipped subsystem, and it should be -scoped and specified separately rather than folded into this item. Until it is, -`morph::flows` blocks this removal. - -## API reference - -| Symbol | Signature | Meaning | -|---|---|---| -| `handler.subscribe(cb)` | `void(std::function)` | Fire `cb` whenever an `R` is produced on the attached instance. Replaces any prior callback for `R`. | -| `handler.unsubscribe()` | `void` | Drop the callback for `R`. | - -## Design decisions - -- **Keyed on the result type, not the action type.** A subscriber describes what - it renders, not what someone else must call. Detailed above. -- **The originator is notified too.** No "was this mine" bookkeeping in every - subscriber. -- **Best-effort, unbuffered, no replay.** Durable streams with cursors and - checkpoints are a distributed-runtime feature; morph is a UI bridge. A client - that missed a notification re-reads state, which it already knows how to do. -- **Connection-scoped subscriptions.** They die with the transport, so there is - no cleanup story beyond the one `closeConnection` already implements. -- **Gated by `authorize` on the model type.** A subscription is a read channel; - leaving it ungated would let a principal observe results it may not request. -- **The draft mechanism is removed rather than kept alongside.** Two mechanisms - both named "subscription", with different keying and different scopes, is the - kind of ambiguity the specs exist to prevent. - -## Failure modes - -- **A slow or blocked subscriber.** Notifications are posted to the subscriber's - executor; a subscriber that blocks its executor delays its own callbacks and - nothing else. It must not be able to stall the producing instance's strand — - the notification is handed off, never awaited. -- **Notification storms.** A model producing a result per keystroke notifies - every attached client per keystroke. There is no coalescing (the draft - mechanism's coalescing is being removed, not carried over). A model that emits - at high frequency must throttle itself. -- **Uncorrelated frames in older clients.** A client built before this change - treats an unsolicited `notify` as a protocol error. Servers must only send - notifications to connections that subscribed, which by construction are new - clients — but the negotiated protocol version from A6 should gate it - explicitly rather than relying on that. -- **Subscription outliving its instance.** When an instance is destroyed - (attach count reaches zero) its subscriptions are dropped. A handler still - holding a callback for it simply stops hearing anything; re-attaching - re-establishes the subscription. -- **Result type collision across models.** Two model types producing the same - result type are distinguished by model id, not by result type alone; the - server's map is keyed on `(modelId, resultTypeId)`. - -## Limitations - -- **No replay, no durability, no ordering across instances.** Stated above. -- **No filtering.** A subscriber receives every `R` produced on the instance; it - cannot ask for a subset. -- **No subscription to a model type in general** — only to a specific attached - instance. "Tell me about every account" is not expressible. -- **No back-pressure.** A producer never learns that a subscriber is slow. -- **`morph::flows` must be reworked first.** This removal cannot land while - `FlowSession` depends on the mechanism it deletes. - -## Cross-references - -- [shared_model_instances.md](shared_model_instances.md) — instances, - attachment, and re-pointing, which define a subscription's scope. -- [stateful_bank_example.md](stateful_bank_example.md) — the state types a - subscriber would name. -- [bridge.md](../spec/core/bridge.md) — the draft mechanism being removed. -- [wire.md](../spec/core/wire.md) — the envelope, and the request/reply - assumption this breaks. -- [backend.md](../spec/core/backend.md) — connection scopes and the transports - that need an unsolicited-message path. -- [registry.md](../spec/core/registry.md) — `ActionValidator`, which survives - with a narrowed role. -- [workflows_navigation.md](../spec/forms/workflows_navigation.md) — - `FlowSession`, which must be reworked first. -- [VERSIONING.md](../spec/VERSIONING.md) — the pre-1.0 latitude this removal - relies on. diff --git a/docs/planned/stateful_bank_example.md b/docs/planned/stateful_bank_example.md deleted file mode 100644 index a63ab2d7..00000000 --- a/docs/planned/stateful_bank_example.md +++ /dev/null @@ -1,326 +0,0 @@ -# Reshaping `examples/bank` onto stateful models — planned - -**Status:** planned, not implemented. This document is a design proposal, not a -description of current behaviour. The authoritative present-tense specs are in -[`docs/spec/`](../spec). - -## Contents - -- [The gap this closes](#the-gap-this-closes) -- [Why the current bank cannot demonstrate morph](#why-the-current-bank-cannot-demonstrate-morph) -- [The reshaped model set](#the-reshaped-model-set) -- [`AccountModel` — the worked example](#accountmodel--the-worked-example) -- [`CustomerModel` — the per-user repository](#customermodel--the-per-user-repository) -- [`LedgerModel` — where cross-instance atomicity lives](#ledgermodel--where-cross-instance-atomicity-lives) -- [Hydration, write-through, and deactivation](#hydration-write-through-and-deactivation) -- [What the GUI stops doing](#what-the-gui-stops-doing) -- [The WASM build](#the-wasm-build) -- [Migration order](#migration-order) -- [Design decisions](#design-decisions) -- [Failure modes](#failure-modes) -- [Limitations](#limitations) -- [Cross-references](#cross-references) - -## The gap this closes - -morph's central claim is in the README's first paragraphs: *you write the model -as plain, single-threaded C++, and the framework owns concurrency — one strand -per model instance serialises that model's calls, so model authors never touch a -mutex.* - -`examples/bank` is the library's largest worked example and the one a reader -reaches for to see that claim in action. It does not demonstrate it. Every bank -model is **stateless**: the only member any of them declares is the -`std::optional` inherited from -`bank::db::WithMapper` — a database connection, not domain state. - -That means the per-model strand protects nothing. There is no state to -serialise access to, no state to keep in memory, and no state that a second -handler could usefully share. Every action is a full round trip to SQLite, so -the example demonstrates the *bridge* while leaving morph's model layer looking -like a thin RPC shim over a database. - -This document proposes reshaping the bank so its models hold the state they are -named after. It is a prerequisite for -[shared_model_instances.md](shared_model_instances.md) and -[instance_subscriptions.md](instance_subscriptions.md) having any demonstrable -effect: a primary key identifies nothing when instances carry nothing, and -sharing an instance preserves nothing when there is nothing to preserve. - -## Why the current bank cannot demonstrate morph - -Concretely, today: - -```cpp -class AccountModel : private db::WithMapper { -public: - dto::AccountInfo execute(const dto::OpenAccount&); - dto::AccountList execute(const dto::ListAccounts&); - dto::AccountInfo execute(const dto::GetAccount&); - dto::CommandResult execute(const dto::CloseAccount&); -}; -``` - -One `AccountModel` answers for **every** account of **every** user. Its -identity is nothing; its state is nothing. Three consequences follow, all -visible in the shipped GUI: - -- **Five instances, five connections.** `AccountController`, - `TransactionController`, `LoanController`, `CardController` and - `PayeeController` each construct a `BridgeHandler`. Since - a handler registers one instance ([bridge.md](../spec/core/bridge.md)), the - desktop GUI holds five `AccountModel` instances and therefore opens five - SQLite connections for what is logically one thing. -- **Reads cost a query.** `GetAccount` re-selects a row the process may have - read a millisecond earlier, because nothing retains it. -- **The strand is decorative.** Its documented purpose is to let a model own - mutable state without locking. No bank model has any. - -## The reshaped model set - -The reshape splits today's per-*domain* models into models keyed by the entity -they are actually about. Each keyed model type declares its key as a nested -alias, detected structurally (see -[shared_model_instances.md](shared_model_instances.md)). - -| Model | Key | In-memory state | Actions | -|---|---|---|---| -| `AccountModel` | `AccountId` (account row id) | one `AccountRecord`: balance, status, overdraft, currency, kind | `GetAccount`, `Deposit`, `Withdraw`, `CloseAccount` | -| `CustomerModel` | `UserId` (owner) | the customer row + their account id list | `ListAccounts`, `OpenAccount` | -| `LedgerModel` | *(unkeyed)* | none — owns the atomic write | `Transfer`, `History` | -| `AuthModel` | *(unkeyed)* | none | `Login`, `Logout` | - -`LoanModel`, `CardModel`, `PayeeModel`, `PaymentModel`, `StatementModel`, -`BudgetModel` and `NotificationModel` keep their current shape in the first -pass; see [Migration order](#migration-order). - -The split is the point: `ListAccounts` was never an account-scoped operation — -it is scoped by *user*, which is why the current single model has to take an -`owner` field on half its actions. Once `AccountModel` is keyed by account, -those fields disappear, because the instance already knows which account it is. - -## `AccountModel` — the worked example - -```cpp -namespace bank { - -/// One customer account, held in memory for the lifetime of the instance. -class AccountModel : private db::WithMapper { -public: - /// The primary key type. Detected structurally by morph; declaring it is - /// what makes this model keyed. - using PrimaryKey = std::int64_t; - - dto::AccountInfo execute(const dto::GetAccount&); - dto::AccountInfo execute(const dto::Deposit&); - dto::AccountInfo execute(const dto::Withdraw&); - dto::CommandResult execute(const dto::CloseAccount&); - -private: - void hydrate(); ///< load `_row` from SQLite on first use - void writeThrough(); ///< persist `_row` after a mutation - - db::AccountRecord _row{}; ///< the account — in memory, not re-queried - bool _loaded = false; -}; - -} // namespace bank -``` - -The action DTOs lose the id fields that only existed to say *which* account: - -```cpp -// before // after -struct GetAccount { std::int64_t id; }; struct GetAccount {}; -struct Deposit { std::int64_t accountId; struct Deposit { std::int64_t amountMinor; }; - std::int64_t amountMinor; }; -struct CloseAccount { std::int64_t id; }; struct CloseAccount {}; -``` - -and the key is instead declared once per action, naming the field that carries -it — or, for actions that no longer carry one, nothing at all, in which case the -action runs on whichever instance the handler is already attached to: - -```cpp -BRIDGE_REGISTER_MODEL(AccountModel, "AccountModel") -BRIDGE_REGISTER_ACTION(AccountModel, GetAccount, "GetAccount", Loggable::No) -BRIDGE_REGISTER_ACTION(AccountModel, Deposit, "Deposit") -BRIDGE_REGISTER_ACTION(AccountModel, Withdraw, "Withdraw") -BRIDGE_REGISTER_ACTION(AccountModel, CloseAccount, "CloseAccount") -``` - -The GUI attaches by key and then stops mentioning ids: - -```cpp -BridgeHandler account{bridge, gui}; - -account.attach(42); // or: any keyed action re-points it -account.execute(Deposit{.amountMinor = 5000}) - .then([](AccountInfo a) { /* a.balanceMinor is authoritative, from memory */ }); -``` - -`Deposit` now reads and writes `_row.balanceMinor` directly. The overdraft check -that today re-selects the row is a field comparison. The strand that morph has -always provided is now load-bearing: it is what makes the unlocked -read-modify-write of `_row` correct. - -## `CustomerModel` — the per-user repository - -```cpp -class CustomerModel : private db::WithMapper { -public: - using PrimaryKey = std::int64_t; // user id - - dto::AccountList execute(const dto::ListAccounts&); // no `owner` field - dto::AccountInfo execute(const dto::OpenAccount&); // no `owner` field -}; -``` - -`OpenAccount` is the *creating* action: it inserts a row and its result carries -the new id. That is the result-sourced key case in -[shared_model_instances.md](shared_model_instances.md) — a handler can adopt the -new account's key straight from the result, exactly as a database insert -returns its generated primary key: - -```cpp -BRIDGE_KEY_FROM_RESULT(OpenAccount, &dto::AccountInfo::id) -``` - -`CustomerModel`'s key comes from the authenticated principal rather than an -action field. The first pass resolves it explicitly at login -(`customer.attach(session.userId)`); making the session principal a first-class -key source is deliberately **not** part of this work — see -[Limitations](#limitations). - -## `LedgerModel` — where cross-instance atomicity lives - -`Transfer` moves money between two accounts, so with per-account instances it -touches two models. morph has no cross-instance transaction and this proposal -does not add one — consistent with the framework's standing position that -conflict resolution and multi-entity consistency are domain concerns -([ARCHITECTURE.md](../ARCHITECTURE.md), "Conflict Resolution — a domain concern, -not a framework concern"). - -`Transfer` therefore stays on an unkeyed `LedgerModel` which owns the -`SqlTransaction` that debits one row and credits the other atomically, exactly -as today. The consequence is explicit and must be documented in the example's -README: **after a transfer, any live `AccountModel` instance for either account -holds a stale balance.** The first pass resolves this the blunt way — the ledger -marks both instances dirty and they re-hydrate on their next action. -[instance_subscriptions.md](instance_subscriptions.md) is what would let the GUI -learn about it without asking. - -This is the sharpest honest edge of the whole design, and the example should -show it rather than arrange the domain to avoid it. - -## Hydration, write-through, and deactivation - -- **Hydration is lazy and on-strand.** The first action on an instance loads its - row, on the strand thread, mirroring how `WithMapper` already defers opening - the `DataMapper`. A key naming a row that does not exist fails that action - with `NotFound`; the instance is not retained. -- **Writes are write-through, not write-behind.** A mutating action updates - `_row` and persists it before returning. This keeps SQLite authoritative, so a - crash loses nothing and a deactivated instance can always be reconstructed. - Write-behind would be faster and is explicitly out of scope: it would make the - in-memory copy authoritative and demand a durability story the example has no - business inventing. -- **Deactivation just drops memory.** Releasing an instance discards `_row`; the - database is unchanged. Re-attaching re-hydrates. Nothing in the example - depends on an instance surviving. - -## What the GUI stops doing - -The five `BridgeHandler` become `AllowShared` handlers attached to -the account the user is looking at, so the desktop GUI holds one instance per -*viewed account* rather than one per *controller*. `TransactionController` and -`LoanController` stop constructing their own `AccountModel` purely to re-list -accounts; they attach to `CustomerModel` instead. - -`AccountController::refresh()`'s `ListAccounts` round trip after every mutation -(`AccountController.cpp:63`, `TransactionController.cpp:97`) is not removed by -this work — that is the invalidation problem, out of scope here — but it becomes -cheaper, because the balance the GUI re-reads comes from memory. - -## The WASM build - -`examples/bank/gui_wasm` carries shadow model headers and an in-memory store -(`gui_wasm/include/bank/wasm/store.hpp`) that reimplement every model against a -non-SQLite backing. Those shadows must be reshaped in the same commit, or the -WASM demo silently diverges from the desktop one. - -The reshape is *easier* there: a stateful model over an in-memory store is -closer to what the WASM shadows already are. This is a good forcing function — -if the reshaped model is awkward to express against a plain in-memory store, the -model is carrying persistence concerns it should not. - -## Migration order - -1. `AccountModel` + `CustomerModel` + `LedgerModel`, desktop only. This is the - whole idea; everything after it is repetition. -2. The `gui_wasm` shadows for the same three. -3. `examples/bank/tests` — the per-model tests become per-instance tests, which - is where the state actually gets asserted. -4. `LoanModel` and `CardModel` (keyed by loan / card id), same pattern. -5. `PayeeModel`, `PaymentModel`, `StatementModel`, `BudgetModel`, - `NotificationModel` — keyed by owner, i.e. `CustomerModel`-shaped. -6. `examples/bank/README.md`, whose "Architecture: two type layers" section - describes the stateless shape and must be rewritten. - -Steps 1–3 are the deliverable; 4–6 can follow independently. - -## Design decisions - -- **Split by entity, not by domain.** The current models are named for domains - (`AccountModel` handles all accounts). Keying them by the entity they are - named after is what gives the key something to identify. The `owner` fields - scattered across today's DTOs are the symptom of the missing split. -- **SQLite stays authoritative.** The model holds a cache with identity, not a - system of record. This keeps the example honest about what morph does and does - not own, and keeps `journal`'s replay semantics - ([journal.md](../spec/journal/journal.md)) unchanged. -- **`Transfer` stays on an unkeyed model.** Making it a cross-instance operation - would require inventing cross-strand atomicity, which morph does not have and - which this example must not imply it has. -- **No session-sourced keys in this pass.** `CustomerModel` attaching from an - explicit user id keeps the key mechanism to one concept. - -## Failure modes - -- **A key naming a non-existent row.** Hydration fails, the action completes - through `onError` with `NotFound`, and no instance is retained. It must not - leave a half-hydrated instance in the directory. -- **A stale `AccountModel` after `Transfer`.** Documented above and visible in - the example by design. The dirty-and-re-hydrate mitigation must be an - explicit, commented mechanism, not an accident of timing. -- **Two instances for the same account** — impossible for `AllowShared` handlers - (the directory guarantees one per key) but expected for plain handlers, which - keep today's isolated-instance behaviour. Two isolated instances of the same - account both write through to the same row, so the last writer wins. The - example should use `AllowShared` throughout and say why. - -## Limitations - -- **The session principal is not a key source.** `CustomerModel` must be - attached explicitly after login. Deriving a key from the authenticated - principal is a plausible follow-up, not part of this work. -- **No cross-instance transaction.** Stated above; a domain concern by design. -- **Write-through only.** No batching, no write-behind, no dirty-flush policy. -- **The first pass leaves six models unreshaped**, so the example is - temporarily mixed-paradigm. The README must say which models are which rather - than let a reader infer that the un-migrated ones are the intended pattern. - -## Cross-references - -- [shared_model_instances.md](shared_model_instances.md) — the keyed-instance - mechanism this example is the demonstrator for. -- [instance_subscriptions.md](instance_subscriptions.md) — how a GUI learns that - a shared instance changed. -- [bridge.md](../spec/core/bridge.md) — `BridgeHandler`, `HandlerBinding`, and - the one-instance-per-handler rule this reshape works within. -- [registry.md](../spec/core/registry.md) — `BRIDGE_REGISTER_*`, model factories, - and the default-constructibility requirement for remotely instantiated models. -- [journal.md](../spec/journal/journal.md) — `contextKey` as an entity key, which - a keyed model supplies naturally. -- [ARCHITECTURE.md](../ARCHITECTURE.md) — "Conflict Resolution — a domain - concern, not a framework concern". diff --git a/docs/spec/core/bridge.md b/docs/spec/core/bridge.md index 0f6eea08..162e5a0a 100644 --- a/docs/spec/core/bridge.md +++ b/docs/spec/core/bridge.md @@ -49,10 +49,9 @@ registers on construction, deregisters on destruction. (compile-time dispatch) and `executeJson(actionType, bodyJson)` (runtime dispatch via `ActionExecuteRegistry`). -For GUI-led workflows, `subscribe(cb)` registers a result callback, -`set<&Action::field>(value)` fills one field of an in-progress draft, and -`reset()` discards the draft. When all required fields are filled, the -handler automatically fires the action. +For GUI-led workflows, `subscribe(cb)` observes *the instance the handler is +attached to*: it fires whenever an `R` is produced there, by any handler +attached to it. `unsubscribe()` drops the callback. ## `HandlerBinding` @@ -213,8 +212,8 @@ destruction. Non-copyable. **Construction** takes a `Bridge&` and a GUI executor. Optionally accepts a pre-built `HandlerBinding` (for dependency injection). It captures the bridge's `liveness()` weak token into `_bridgeAlive`, holds a strong `Bridge&`, and -stores a `SubscriberState` shared pointer that supports the field-by-field and -subscription APIs. +and carries the sharing policy as its second template argument (see +`BridgeHandler` below). **Destruction** deregisters the binding via `Bridge::deregisterHandler` — but only if `_bridgeAlive.lock()` still succeeds. If the `Bridge` was already @@ -259,52 +258,23 @@ the reactive `set<>` path rather than trusting the raw wire body: if it returns `false` the executor throws `std::invalid_argument` and the completion resolves through `onError` (a proper error reply upstream) — the handler is never invoked with an invalid action. This closes a gap where the - request/reply path skipped the readiness/validity check that the reactive - `set<>` path (`tryFireImpl`) already performs. `ActionValidator::ready` + request/reply path skipped the readiness/validity check the dispatch paths + perform. `ActionValidator::ready` auto-detects a `bool validate() const` member and defaults to `true`, so actions without a validator dispatch exactly as before (backward compatible). -**`subscribe(cb)`** / **`subscribe(cb, errCb)`** stores a -result (and optional error) callback keyed by -`ActionTraits::typeId()`. Callbacks execute on the GUI executor. +**`unsubscribe()`** removes this handler's callback for result type `R`. -**`unsubscribe()`** clears both result and error callbacks for the -action type. - -**`set<&Action::field>(value)`** updates one field of the in-progress draft. -Uses `MemberPointerTraits` to recover the action and field types from the -pointer-to-member. After setting the value, recomputes any declared computed -fields on the snapshot (`morph::forms::recomputeAll`, [forms.md](../forms/forms.md), -a no-op for actions with no `computedFields`), then checks -`ActionValidator::ready(snapshot)`. If all required fields are -present, fires the action via `Bridge::executeVia` and delivers the result +**`subscribe(cb)`** registers `cb` against this handler's binding. The +subscription is matched at publish time by comparing the binding's current +instance, so it follows the handler when it re-points. Delivers the result to the registered `sink` callback. If a flight is already in progress, marks `pending = true` and refires when the current flight completes (debounce-like coalescence). On failure, the registered `errSink` is invoked; if none is registered, the error is logged via `morph::log::logError` -(tagged `[subscription:]`) rather than silently dropped. - -**`reset()`** discards the in-progress draft. **`guiExecutor()`** returns the executor passed at construction. -### SubscriberState - -```cpp -struct SubscriberState { - std::mutex mtx; - Bridge* bridge; - std::shared_ptr binding; - IExecutor* guiExec; - std::unordered_map entries; -}; -``` - -Keys are `std::string_view` pointing to `ActionTraits::typeId()` string -literals (static storage duration — keys never dangle). Each `SubscriberEntry` -holds a `draft` (`std::any` of the action struct), `sink`, `errSink`, and -`running`/`pending` flags for flight tracking. - ## `ActionExecuteRegistry` Process-level singleton (`instance()`). Maps `(modelTypeId, actionTypeId)` @@ -369,54 +339,48 @@ struct MemberPointerTraits { }; ``` -Compile-time decomposition of a pointer-to-data-member type. Used by -`BridgeHandler::set` to recover both the action type (`A`) -and the field type (`V`) from a single non-type template parameter, so -callers write `handler.set<&MyAction::c>(7.0)` with no redundant type -arguments. +Compile-time decomposition of a pointer-to-data-member type. Recovers both the +class (`A`) and the member type (`V`) from a single non-type template +parameter, so a caller names a field as `&MyAction::c` with no redundant type +arguments. Used by `morph::flows::FlowSession::set<>` +([workflows_navigation.md](../forms/workflows_navigation.md)) and by +`morph::forms`' computed-field declarations. ## Subscription semantics -The fielded/reactive surface (`subscribe`, `set`, `unsubscribe`, `reset`) is -built on the per-handler `SubscriberState`. Exactly one `SubscriberEntry` -exists per action type, keyed by `ActionTraits::typeId()`. The rules: - -- **One subscriber per `(handler, action type)`.** `subscribe(cb)` (or the - two-argument `subscribe(cb, errCb)`) *replaces* any callback previously - registered for `A` on this handler — there is no fan-out. The two-argument - overload additionally stores the error sink; the one-argument overload leaves - the existing `errSink` untouched. -- **Fire without a subscriber.** A `set<>`-triggered fire runs whether or not a - subscriber is installed. If no `sink` is registered when the result arrives, - the result is simply dropped (the entry exists because `set<>` created the - draft, but its `sink` is empty). Errors are different: with no `errSink` the - error is logged via `morph::log::logError` tagged `[subscription:]`, - never silently dropped. -- **Default validator fires on the first `set<>`.** `ActionValidator::ready` - returns `true` only for an action that has *neither* a - `BRIDGE_REGISTER_VALIDATOR` specialisation *nor* a `bool validate() const` - member — for such an action the very first `set<>` puts the - (single-field-populated) draft into a ready state and dispatches immediately. - Actions that need several fields before firing add a `validate()` member (the - preferred, macro-free path — auto-detected via the `HasValidate` concept) or - specialise the validator. -- **Every ready `set<>` re-fires.** Each `set<>` recomputes any declared - computed fields on the draft snapshot (`morph::forms::recomputeAll`, - [forms.md](../forms/forms.md), a no-op for actions with no - `computedFields`) before the `ready()` check, then — landing a - `ready()==true` snapshot — dispatches the action again with the recomputed - value already in place: live recomputation. This recompute is client-side - and **not authoritative** (for display only); every dispatch path below - recomputes it again, authoritatively, before `Model::execute`. Rapid patches - coalesce: while a flight is running, further `set<>` calls set - `pending=true`, and exactly one re-fire with the latest snapshot is issued - when the in-flight completion resolves (`consumeFlight`). -- **Draft lifetime.** A draft is created lazily on the first `set<>` for its - action type and persists across fires, across `unsubscribe()`, and across - `Bridge::switchBackend` (the draft lives in the handler's `SubscriberState`, - not the backend). It is destroyed only when the handler is destroyed or when - `reset()` is called. `unsubscribe()` clears both callbacks but leaves - the draft intact. +`subscribe(cb)` is keyed on the **result/state type**, not on an action. It +fires whenever an `R` is produced on the instance the handler is attached to — +by this handler, by another handler sharing that instance, or by another screen +entirely. The subscriber names *what it renders*, not what somebody else must +call to produce it, so adding an action that also yields an `R` never breaks an +existing subscriber. + +- **Scope is the instance, not the model type.** A subscription is matched at + publish time by comparing the binding's *current* instance, so re-pointing a + handler ([`attach`](#bridgehandlermodel-sharing)) moves its subscriptions with + it. A handler with no primary hears only its own results, because nothing else + is attached to what it holds. +- **One callback per `(handler, R)`.** Subscribing again replaces the previous + callback; there is no fan-out within a handler. +- **The originating handler is notified too.** Suppressing the echo would force + every subscriber to special-case "was this mine", which is exactly the + bookkeeping this replaces. +- **Callbacks run on the handler's executor**, as `.then` does. Two handlers in + one process with different executors each get their callback where they asked + for it. +- **Ordering is per instance.** Every action on an instance runs on that + instance's strand, so notifications are naturally ordered; nothing is + guaranteed *between* instances. +- **Failed actions notify nobody.** A notification is produced from a successful + result only. +- **Delivery is best-effort and unbuffered.** There is no replay, no cursor, no + checkpointing, and no coalescing — a model that emits at high frequency must + throttle itself. Sinks are snapshotted under the registry lock and invoked + outside it, so a subscriber that re-enters the bridge cannot deadlock. +- **Only copy-constructible results are published.** A result type that cannot + be copied is delivered to its caller's `Completion` as usual but is never + boxed for subscribers. + ## Thread safety @@ -424,16 +388,14 @@ exists per action type, keyed by `ActionTraits::typeId()`. The rules: `_backendMtx`, `_mtx`, and `_sessionMtx`, with `executeVia` taking its backend snapshot under the short, dedicated `_backendMtx` rather than `_mtx`). -`BridgeHandler`'s individual mutating operations — `set`, `subscribe`, -`unsubscribe`, `reset` — are each internally safe: they take the -`SubscriberState::mtx` while touching the entry map. Result and error callbacks -never run under that mutex; they are marshalled to the `guiExec` executor -passed at construction, and `tryFireImpl` captures a `weak_ptr` -so a callback that fires after the handler is destroyed is a no-op. The -intended usage is nonetheless **single-GUI-thread affinity**: a handler and its -subscriptions belong to one GUI thread, and interleaving concurrent `set<>` -storms from multiple threads onto the same handler, while memory-safe, has no -defined ordering guarantee beyond the per-operation locking. +`subscribe`/`unsubscribe` mutate the bridge's subscription registry under +`_subMtx`. Callbacks never run under that mutex: `publishResult` snapshots the +matching sinks under the lock and invokes them outside it, marshalled to the +`guiExec` executor passed at construction, so a subscriber that re-enters the +bridge cannot deadlock. A subscription holds a `weak_ptr` to its binding, so one +belonging to a destroyed handler is skipped and pruned rather than dangling. The +intended usage remains **single-GUI-thread affinity**: a handler and its +subscriptions belong to one GUI thread. ## Lifetime & ownership @@ -496,11 +458,11 @@ make teardown order-independent.) | dtor | `~BridgeHandler()` | Deregisters via `Bridge::deregisterHandler`, but only if the bridge's liveness token is still alive; a no-op if the `Bridge` was already destroyed. | | `execute` | `Completion execute(Action)` | Typed dispatch through the bridge. | | `executeJson` | `Completion executeJson(string_view actionType, string_view bodyJson)` | Type-erased dispatch through `ActionExecuteRegistry`. | -| `subscribe(cb)` | `void subscribe(function)` | Result callback. | -| `subscribe(cb, errCb)` | `void subscribe(function, function)` | Result + error callbacks. | -| `unsubscribe` | `void unsubscribe()` | Clears both callbacks. | -| `set` | `void set(MemberPointerTraits::ValueType value)` | Field-by-field update; auto-fires when ready. | -| `reset` | `void reset()` | Discards in-progress draft. | +| `subscribe(cb)` | `void subscribe(function)` | Fire `cb` whenever an `R` is produced on the attached instance. | +| `unsubscribe` | `void unsubscribe()` | Drops this handler's callback for `R`. | +| `attach(key)` | `void attach(const PrimaryKeyOf&)` | Attaches/re-points a shared handler. | +| `primary()` | `optional> primary()` | The handler's current primary, or empty. | +| `instances()` | `Completion>> instances()` | Snapshot of live shared keys. | | `guiExecutor` | `IExecutor* guiExecutor() const noexcept` | Returns the callback executor. | | `binding` | `const shared_ptr& binding() const` | Returns the underlying binding. | @@ -518,13 +480,13 @@ make teardown order-independent.) | Decision | Choice | Why | |---|---|---| | Binding storage | **`vector>`** | `Bridge` does not own the bindings — `BridgeHandler` holds the `shared_ptr`. Weak references let `switchBackend` and the reconnect handler skip dead bindings without keeping handlers alive. (Handler *teardown* after the bridge is made safe separately, by the `_liveness` token — not by this weak storage.) | -| Teardown order | **`shared_ptr _liveness` + per-handler `weak_ptr`** | Makes bridge-vs-handler destruction order-independent: a handler outliving its bridge skips deregistration instead of dereferencing a dangling `Bridge&`. Normal `execute`/`set` still require the bridge to outlive its handlers. | +| Teardown order | **`shared_ptr _liveness` + per-handler `weak_ptr`** | Makes bridge-vs-handler destruction order-independent: a handler outliving its bridge skips deregistration instead of dereferencing a dangling `Bridge&`. Normal `execute`/`subscribe` still require the bridge to outlive its handlers. | | Backend pointer | **Short snapshot under the dedicated `_backendMtx`** | `executeVia()` reads the backend through a `loadBackend()` helper that copies the `shared_ptr` under `_backendMtx` (never `_mtx`), so it never blocks on `switchBackend()`'s `_mtx`. | | Session storage | **Separate `_sessionMtx` from `_mtx`** | Session access is a hot path (every `executeVia` reads it). A separate mutex avoids contention with handler registration/switchBackend. | | Reconnect handler | **Liveness guard + weak‑backend guard + stale check; cleared in `~Bridge`** | The lambda captures a `weak_ptr` to `_liveness` and a `weak_ptr`. On invocation it first locks the liveness token — if the `Bridge` is gone it returns without touching `this` (no use-after-free). It then checks `pinned == loadBackend()` — if a switch occurred since the handler was installed, the reconnect is ignored. `~Bridge` and `switchBackend` also clear the outgoing backend's handler via `setReconnectHandler(nullptr)`; the liveness guard covers a reconnect already in flight when teardown races it. | -| Fielded actions | **`SubscriberState` shared across `BridgeHandler` copy-unsafe design** | The handler is non-copyable; the subscriber state is `shared_ptr` so `tryFireImpl` can capture a `weak_ptr` and survive handler destruction. Flight tracking (`running`/`pending`) coalesces rapid `set` calls. | +| Subscription keying | **On the result type, and against the binding rather than an instance id** | A subscriber is a renderer: it cares about the state it draws, not about which of several actions produced it, so a new action yielding the same type never breaks it. Storing against the binding makes a subscription follow a re-pointed handler, which is what "tell me about the account I am looking at" requires. | | Action readiness | **`ActionValidator::ready(snapshot)`** | Framework-agnostic validation — each action struct defines its own required-field semantics. The bridge never interprets action fields. | -| Local-path validation enforcement | **`localOp` checks `ActionValidator::ready` before `Model::execute`** | Closes the gap where an `Action` built by hand and dispatched via `BridgeHandler::execute()` (bypassing the reactive `set<>` gate) reached the model unvalidated; mirrors `ActionDispatcher::registerAction`'s server-side runner (`registry.md`). Backward compatible: `ready()` defaults to `true` for actions with no validator. | +| Local-path validation enforcement | **`localOp` checks `ActionValidator::ready` before `Model::execute`** | Closes the gap where an `Action` built by hand and dispatched via `BridgeHandler::execute()` (without a client-side gate) reached the model unvalidated; mirrors `ActionDispatcher::registerAction`'s server-side runner (`registry.md`). Backward compatible: `ready()` defaults to `true` for actions with no validator. | | Subscription keys | **`string_view` into static storage** | `ActionTraits::typeId()` returns `constexpr` string literals with static duration. The `unordered_map` holds non-owning keys; no allocation, no lifetime issues. | | `executeJson` | **Separate registry, not a vtable** | The action type is unknown at the call site. A flat `unordered_map<(modelId, actionId), Executor, PairKeyHash>` lets any translation unit register its actions without central registration or RTTI. | | `registerActionExecutorOnce` | **`inline` definition in header** | The function is forward-declared in `registry.hpp` (`morph::model::detail`) but defined `inline` in `bridge.hpp`, after `ActionExecuteRegistry`. `inline` lets that definition be instantiated in every TU that transitively includes `bridge.hpp` without an ODR/link violation. The registration runs from the anonymous-namespace initializer the macro emits. Because the definition lives only in `bridge.hpp`, any TU expanding `BRIDGE_REGISTER_ACTION` must include it (directly or transitively) or the link fails with an unresolved symbol. | diff --git a/docs/planned/shared_model_instances.md b/docs/spec/core/shared_instances.md similarity index 90% rename from docs/planned/shared_model_instances.md rename to docs/spec/core/shared_instances.md index 844dd648..bb49cae2 100644 --- a/docs/planned/shared_model_instances.md +++ b/docs/spec/core/shared_instances.md @@ -1,8 +1,4 @@ -# Keyed, shareable model instances — planned - -**Status:** planned, not implemented. This document is a design proposal, not a -description of current behaviour. The authoritative present-tense specs are in -[`docs/spec/`](../spec). +# Keyed, shareable model instances — design ## Contents @@ -38,7 +34,7 @@ The cost is visible in `examples/bank`: five controllers each construct a `Lightweight::DataMapper`, five SQLite connections — for what is logically one thing. -Once models hold state ([stateful_bank_example.md](stateful_bank_example.md)) +Once models hold state ([the bank example](../../../examples/bank/README.md)) the problem stops being wasteful and starts being wrong: five instances of account 42 are five divergent copies of that account's balance. @@ -49,24 +45,24 @@ Most of the mechanism is present and only needs connecting. - **A stable per-instance identity already exists.** `HandlerBinding::contextKey` is documented as "stable identity of this model instance (e.g. an account id)" and already travels in the `register` wire envelope - ([wire.md](../spec/core/wire.md)). It is used **only** for journal entity keys + ([wire.md](wire.md)). It is used **only** for journal entity keys and server-side log attachment; `wire.md`'s design-decision table states explicitly that `contextKey` plays no part in instance routing. The vocabulary is there; the routing is not. - **Structural trait detection is an established pattern.** - [views.md](../spec/forms/views.md) detects `kind`, `query`, `title`, `rowKey` + [views.md](../forms/views.md) detects `kind`, `query`, `title`, `rowKey` and friends "via a `requires`-expression, not inheritance or a marker base". A model's key type is declared the same way. - **Per-instance authorization exists.** `IAuthorizer::authorizeInstance` is consulted on every `execute` and every `deregister`, carrying the instance id - and its recorded owner ([session.md](../spec/session/session.md)). + and its recorded owner ([session.md](../session/session.md)). - **One strand per instance** already gives a shared instance the serialisation it needs; sharing an instance changes nothing about how its actions run. ## Declaring a primary key A model declares its key type as a nested alias. Declaring it is what makes the -model keyed; a model without it keeps today's behaviour exactly. +model keyed; a model without it behaves exactly as an unkeyed model always has. ```cpp class AccountModel { @@ -174,7 +170,7 @@ distinguishes this from a client-side handle cache. The directory maps `(modelTypeId, primaryKey) → ModelId`, held under the same `_regMtx` that guards `_models`/`_owners`, so directory membership can never desync from instance existence — the same invariant the connection-scope map -already maintains ([backend.md](../spec/core/backend.md), "Connection scopes"). +already maintains ([backend.md](backend.md), "Connection scopes"). Only instances created by an `AllowShared` handler are entered. A plain handler's instance is invisible to the directory and unreachable by key. @@ -208,7 +204,7 @@ construction. ## Wire protocol changes Three additive changes. All are compatible with the additive-only evolution -policy in [wire.md](../spec/core/wire.md), and the lenient decoding that A6 +policy in [wire.md](wire.md), and the lenient decoding that A6 established means an older peer ignores what it does not understand. - **`register` grows `primary` and `shared`.** `primary` is the key as a string @@ -236,7 +232,7 @@ purposes, which the framework's opt-in discipline forbids. `RemoteServer` records an `ownerPrincipal` for each instance at register time and consults `authorizeInstance` on every execute, with the documented typical policy being `ownerPrincipal.empty() || ownerPrincipal == ctx.principal` -([session.md](../spec/session/session.md)). +([session.md](../session/session.md)). Under that policy, a second client attaching to an instance the first client created would be **rejected**. Cross-client sharing and per-instance ownership @@ -260,13 +256,12 @@ it inside the model, from `Context::principal`, which is the same advice ## Lifetime and the A7 connection-scope change -This item **changes shipped behaviour**, which nothing in the current -`todo.md` program did. It is unavoidable. +This changed shipped A7 behaviour, which nothing in the §A–§E program did. It was +unavoidable. -`closeConnection(cid)` today "erases every model still recorded in `cid`'s -scope". With cross-client sharing, that would destroy an instance another live -client is still attached to. The scope entry must therefore become a -**reference**, not ownership: +`closeConnection(cid)` used to erase every model recorded in `cid`'s scope. With +cross-client sharing that would destroy an instance another live client is still +attached to, so a scope entry is a **reference**, not ownership: - Each attach — from any connection — increments an instance's attach count. - `deregister`, handler destruction, and `closeConnection` each decrement. @@ -367,21 +362,21 @@ strictly reduces pressure on it. operation spanning both is a domain concern, as it is today. - **No state persistence provider.** What an instance holds and how it is loaded is entirely the model's business — see - [stateful_bank_example.md](stateful_bank_example.md). + [the bank example](../../../examples/bank/README.md). ## Cross-references -- [stateful_bank_example.md](stateful_bank_example.md) — the demonstrator; a key +- [the bank example](../../../examples/bank/README.md) — the demonstrator; a key identifies nothing until models hold state. -- [instance_subscriptions.md](instance_subscriptions.md) — how attached handlers +- [bridge.md's subscription semantics](bridge.md#subscription-semantics) — how attached handlers learn that a shared instance changed. -- [bridge.md](../spec/core/bridge.md) — `HandlerBinding`, `contextKey`, +- [bridge.md](bridge.md) — `HandlerBinding`, `contextKey`, `registerHandler`, and `switchBackend`'s re-registration path. -- [backend.md](../spec/core/backend.md) — `RemoteServer`, connection scopes +- [backend.md](backend.md) — `RemoteServer`, connection scopes (A7), and `LimitPolicy`. -- [wire.md](../spec/core/wire.md) — the envelope, additive evolution, and the +- [wire.md](wire.md) — the envelope, additive evolution, and the `contextKey`-vs-`modelId` decision this proposal preserves. -- [session.md](../spec/session/session.md) — `authorizeInstance`, +- [session.md](../session/session.md) — `authorizeInstance`, `authorizeRegister`, and the recorded owner principal. -- [security.md](../spec/security.md) — the per-instance ownership hook and the +- [security.md](../security.md) — the per-instance ownership hook and the trust boundary the ownerless-shared-instance decision sits inside. diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index 83f2031d..1ed5fbf1 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -1128,15 +1128,13 @@ table below); an action that declares no `computedFields` emits neither key. ### Where the value is authoritative -`recomputeAll` runs at four call sites: +`recomputeAll` runs at three call sites, all authoritative: -1. `BridgeHandler::set<>`'s reactive path (`tryFireImpl`, [bridge.md](../core/bridge.md)) - — live, client-side, **not authoritative**, for display only. -2. `ActionExecuteRegistry::registerAction`'s executor (the client-bridge JSON +1. `ActionExecuteRegistry::registerAction`'s executor (the client-bridge JSON dispatch path behind `BridgeHandler::executeJson`, [bridge.md](../core/bridge.md)). -3. `Bridge::executeVia`'s `localOp` (the in-process execution path `LocalBackend` +2. `Bridge::executeVia`'s `localOp` (the in-process execution path `LocalBackend` uses for every `execute()`/`executeJson` call, [bridge.md](../core/bridge.md)). -4. `ActionDispatcher::registerAction`'s runner (the server-side execution path +3. `ActionDispatcher::registerAction`'s runner (the server-side execution path `RemoteServer` uses for `SimulatedRemoteBackend` and the Qt WebSocket transport, [registry.md](../core/registry.md)). @@ -1394,7 +1392,7 @@ wrong or un-merged schema rather than fail loudly. | [rational.md](../util/rational.md) | Exact `Rational` values; the `num`/`den` in each `x-unitAlternatives` entry are a `Rational` numerator/denominator, which is why unit switches recompute exactly. Also the comparison/equality `greater`/`greaterOrEqual`/`less`/`lessOrEqual`/`equals` use for numeric fields, so client and server compare identical values. | | [security.md](../security.md) | The dispatcher's trust boundary — why `required` gates only the client and handlers must re-validate. | | [session.md](../session/session.md) | `Context::locale`, the server-side hook for data (not chrome) localisation — the one place `session::current()->locale` participates, for `Choice` option-row labels. | -| [bridge.md](../core/bridge.md) | The reactive `set<>`/`tryFireImpl` live recompute, and the `ActionExecuteRegistry`/`executeVia` authoritative recompute sites. | +| [bridge.md](../core/bridge.md) | The `ActionExecuteRegistry`/`executeVia` authoritative recompute sites. | | [registry.md](../core/registry.md) | `ActionDispatcher::registerAction`'s runner — the server-side authoritative recompute site. | ## Out of scope diff --git a/docs/spec/forms/workflows_navigation.md b/docs/spec/forms/workflows_navigation.md index 06a24576..4d9218bc 100644 --- a/docs/spec/forms/workflows_navigation.md +++ b/docs/spec/forms/workflows_navigation.md @@ -211,17 +211,14 @@ public: wording ("the renderer resolves each prefill path ... and issues the corresponding `set<>`") — `FlowSession` does not push prefill itself. - **`Steps...` must be pairwise distinct.** Each step type occupies one slot - of both `BridgeHandler`'s per-action-type draft (bridge.md's - "Subscription semantics": exactly one `SubscriberEntry` per action type) - and `FlowSession`'s own `std::get(_drafts)` tuple lookup, which requires + of `FlowSession`'s own `std::get(_drafts)` tuple lookup, which requires a unique type. Reusing the same action type as two steps of one wizard is not supported (`static_assert`-enforced). -- **Backend-switch behaviour is inherited, not reimplemented.** Because - `set<>`/`subscribe<>` are the real `BridgeHandler` calls, an in-flight - step's fire cancelled by `Bridge::switchBackend` surfaces - `BackendChangedError` on `FlowSession`'s `onError` callback exactly as - bridge.md documents for a lone fielded subscriber, and the draft survives - the switch. +- **Backend-switch behaviour is inherited, not reimplemented.** A step is + dispatched with the ordinary `BridgeHandler::execute`, so an in-flight step + cancelled by `Bridge::switchBackend` surfaces `BackendChangedError` on + `FlowSession`'s `onError` callback exactly as bridge.md documents for any + other caller. The draft survives the switch because `FlowSession` owns it. ## The Qt/QML reference renderer @@ -377,9 +374,8 @@ the typed template API (see [Design decisions](#design-decisions)). screen this document reserves but does not yet implement, and the precedent (`CollectionView.qml` shipping in `src/qt/forms`) this spec's `WizardView.qml` placement follows. -- [../core/bridge.md](../core/bridge.md) — `BridgeHandler::set<>`/`subscribe<>`/ - `unsubscribe<>`, `ActionValidator::ready`, draft persistence across fires - and backend switches, and `BackendChangedError` — the mechanism +- [../core/bridge.md](../core/bridge.md) — `BridgeHandler::execute`, + `ActionValidator::ready`, and `BackendChangedError` — the mechanism `FlowSession` extends to span a sequence without adding a new dispatch path. - [../core/registry.md](../core/registry.md) — `ActionTraits::typeId()` and the `BRIDGE_REGISTER_ACTION` pattern `BRIDGE_REGISTER_WIZARD`/`BRIDGE_REGISTER_APP` diff --git a/docs/superpowers/2026-07-06-reactive-forms-bridge.md b/docs/superpowers/2026-07-06-reactive-forms-bridge.md index 0d776c82..35477866 100644 --- a/docs/superpowers/2026-07-06-reactive-forms-bridge.md +++ b/docs/superpowers/2026-07-06-reactive-forms-bridge.md @@ -134,11 +134,9 @@ calls `controller.submitIfValid(actionType, previewLine)` directly. independently, last-writer-wins on `resultText`. If this proves janky, a ~150 ms debounce timer in `DynamicForm.qml` is a self-contained follow-up. -- `BridgeHandler`'s compile-time fielded-draft API - (`set<&Action::field>`/`subscribe`) is not usable generically: - glaze plain-aggregate reflection exposes only field names, not - compile-time member pointers. The generic path goes through - `executeJson` instead. +- A compile-time, per-field API is not usable generically: glaze + plain-aggregate reflection exposes only field names, not compile-time + member pointers. The generic path goes through `executeJson` instead. - The options combo-box fetch is not reactive; it runs once per form from `Component.onCompleted`. - `examples/forms/main.cpp` (schema dump / `--emit-html` / REPL) has no GUI diff --git a/docs/todo.md b/docs/todo.md index f4fa70cd..9b01ff1e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -2,17 +2,17 @@ Two programs live here: -- **[§F — Stateful models](#f-stateful-models-open)** is **open**. It came out of +- **[§F — Stateful models](#f-stateful-models-shipped)** came out of [issue #18](https://github.com/LASTRADA-Software/morph/issues/18) ("compare against Axelor / Jmix / Causeway / Orleans and find out what we are missing"). - Its designs are in [`docs/planned/`](planned). + All three items have shipped. - **§A–§E — Production hardening & GUI generation** is **shipped**, kept for the rationale (priority, dependency order) that motivated the work. Present-tense designs for those are in [`docs/spec/`](spec). --- -# §F. Stateful models (open) +# §F. Stateful models (shipped) ## What the issue #18 survey actually found @@ -42,7 +42,7 @@ already claims: hold state, be identified, be reachable. ## Accepted items -### F1 — Reshape `examples/bank` onto stateful models · P0 · planned +### F1 — Reshape `examples/bank` onto stateful models · P0 · shipped Split the per-domain models into models keyed by the entity they are named after, holding that entity in memory, hydrated on activation and written @@ -50,9 +50,16 @@ through on mutation. This is first deliberately: it is what introduces the main idea of the library, and F2/F3 are unverifiable without it — a primary key identifies nothing when instances carry nothing. -See [`planned/stateful_bank_example.md`](planned/stateful_bank_example.md). +`AccountModel` holds one account in memory, keyed by account id; `CustomerModel` +took the per-owner half (`ListAccounts`/`OpenAccount`), which was never +account-scoped. Cross-model ledger writes settle on another model's connection, +so `bank/db/row_versions.hpp` bumps a per-row counter and cached readers +re-hydrate on a stale version — the example's sharpest edge, shown rather than +arranged away. WASM shadow models and the five GUI controllers moved with it. +See [`examples/bank/README.md`](../examples/bank/README.md), "Stateful, keyed +models". -### F2 — Keyed, shareable model instances · P0 · planned +### F2 — Keyed, shareable model instances · P0 · shipped A model declares a `PrimaryKey`; actions declare which field carries it (or that their *result* establishes it); `BridgeHandler` opts a handler @@ -60,24 +67,34 @@ into a **server-side** instance directory, so instances are reusable across clients. `instances()` enumerates the live keys. A keyed action re-points a handler rather than re-keying an instance, so key collisions do not arise. -Carries a change to shipped behaviour: A7's `closeConnection` must decrement a -reference count rather than erase, or one client's disconnect destroys an -instance another client is using. +Carried a change to shipped behaviour: A7's `closeConnection` now decrements a +reference count rather than erasing, since otherwise one client's disconnect +destroys an instance another client is using. -See [`planned/shared_model_instances.md`](planned/shared_model_instances.md). +See [`spec/core/shared_instances.md`](spec/core/shared_instances.md). -### F3 — Instance subscriptions · P1 · planned +### F3 — Instance subscriptions · P1 · shipped `subscribe(cb)` keyed on the **result/state** type, firing whenever an `R` is -produced on the attached instance by any handler on any connection. Introduces -morph's first server-initiated wire message. +produced on the instance the handler is attached to — by this handler, by +another handler sharing it, or by another screen entirely. -**Removes** the reactive-draft mechanism (`set<&A::field>`, `reset`, the old +**Removed** the reactive-draft mechanism (`set<&A::field>`, `reset`, the old action-keyed `subscribe`, in-flight coalescing), whose job stateful models do -better by holding the draft server-side. Blocked on reworking `morph::flows`, -which is built on the mechanism being deleted. +better by holding the draft themselves. `morph::flows::FlowSession` already +owned its own draft tuple and merely mirrored into the handler's, so it now +gates on `ActionValidator` and dispatches directly; its public API, the +`w-*`/`app-*` schema, and `WizardView.qml` are unchanged. -See [`planned/instance_subscriptions.md`](planned/instance_subscriptions.md). +`ActionValidator` survives with its A1 server-side role, losing only its +draft-readiness one. See +[`spec/core/bridge.md`](spec/core/bridge.md#subscription-semantics) and +[`ARCHITECTURE.md`](ARCHITECTURE.md#instance-subscriptions). + +**Not included:** cross-*client* push. Fan-out is per `Bridge`, so two handlers +in one process — the case the bank GUI actually has — see each other's work, +while two separate clients do not. A server-initiated `notify` frame would need +both transports to grow an unsolicited-message path; that is its own item. ## Considered and refused @@ -104,7 +121,7 @@ Recorded so the survey does not get re-run and so the boundary is explicit. - **`README.md`'s "Status & limitations" is stale.** It still claims the wire protocol has no version negotiation, that `RemoteServer` model ids are guessable sequential integers, and that only an in-memory offline queue - ships — all fixed by §A/§B below. Worth correcting independently of §F. + ships — all fixed by §A/§B below. Corrected as part of this program. --- @@ -184,7 +201,7 @@ design, not a synthesized wire `deregister`. See `spec/core/backend.md#connectio > **F2 changes this.** Cross-client instance sharing requires `closeConnection` > to decrement a reference count rather than erase. See -> [`planned/shared_model_instances.md`](planned/shared_model_instances.md). +> [`spec/core/shared_instances.md`](spec/core/shared_instances.md). --- diff --git a/examples/bank/README.md b/examples/bank/README.md index 220d2d66..34375aa4 100644 --- a/examples/bank/README.md +++ b/examples/bank/README.md @@ -123,7 +123,7 @@ integers), so the GUI and CLI are unaffected; models map the relation values to | **Auth** | register, login, change password, `WhoAmI` (session introspection) | | **Account** | open (checking/savings/credit), list, get, close; overdraft, interest | | **Transaction** | deposit, withdraw, **atomic transfer**, paginated history | -| **Payee** | add (with IBAN validation + `set<>` streaming), remove, list | +| **Payee** | add (with IBAN validation), remove, list | | **Payment** | one-off bill pay, scheduled payments, standing orders, cancel | | **Card** | issue debit/credit, freeze/unfreeze/cancel, set limit, change PIN | | **Loan** | apply (disburse), amortization schedule, repay, payoff | @@ -132,7 +132,7 @@ integers), so the GUI and CLI are unaffected; models map the relation values to | **Statement** | date-ranged credit/debit summary across all accounts | morph features exercised: `Completion` then/onError, **sessions** (principal scoping + -authorization), **validation** (`validate()` + the `set<>`/`subscribe<>` streaming +authorization), **validation** (`validate()` + the dispatch-path validator form flow), **local ↔ remote parity**, a custom **`IAuthorizer`**, and the **offline queue + `SyncWorker`** replay path. From c3dffb6f883bc0724ef30bfc47ca22b647671b79 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 27 Jul 2026 23:54:03 +0200 Subject: [PATCH 07/42] test(bank): move the payee streaming test onto instance subscriptions The last call site of the removed reactive-draft API. It now subscribes to PayeeInfo -- the result type -- and asserts the two properties that replaced the draft readiness gate: an incomplete payee is rejected by the dispatch-path validator, and a failed action notifies no subscriber. Co-Authored-By: Claude Opus 5 (1M context) --- examples/bank/tests/test_payee.cpp | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/examples/bank/tests/test_payee.cpp b/examples/bank/tests/test_payee.cpp index 378f442f..ca8cc36a 100644 --- a/examples/bank/tests/test_payee.cpp +++ b/examples/bank/tests/test_payee.cpp @@ -57,25 +57,32 @@ TEST_CASE("PayeeModel add/list/remove scoped to the owner", "[payee]") { } } -TEST_CASE("PayeeModel form-style streaming via subscribe/set", "[payee][subscribe]") { +TEST_CASE("PayeeModel notifies subscribers of the state it produces", "[payee][subscribe]") { bank::app::App app{testConnection()}; app.login("heidi-form"); morph::bridge::BridgeHandler payees{app.bridge(), app.gui()}; + // Subscribing names the result type, so the observer never has to know + // which action produced it. std::atomic fired{false}; std::int64_t newId = 0; - payees.subscribe([&](bank::dto::PayeeInfo info) { + payees.subscribe([&](bank::dto::PayeeInfo info) { newId = info.id; fired.store(true); }); - // Setting the name alone does not satisfy validate() (no IBAN yet)... - payees.set<&bank::dto::AddPayee::name>("Streamed Payee"); - app.guiLoop().runFor(std::chrono::milliseconds{50}); + // An incomplete payee fails validate() (no IBAN), so the dispatch-path + // validator rejects it and no subscriber is notified. + std::atomic rejected{false}; + payees.execute(bank::dto::AddPayee{.name = "Streamed Payee", .iban = ""}) + .onError([&](const std::exception_ptr&) { rejected.store(true); }); + REQUIRE(waitUntil([&] { return rejected.load(); }, std::chrono::milliseconds{2000}, + std::chrono::milliseconds{5}, app.guiLoop())); REQUIRE_FALSE(fired.load()); - // ...completing the IBAN makes the draft ready and fires the action. - payees.set<&bank::dto::AddPayee::iban>("FR1420041010050500013M02606"); + // A complete one succeeds and the subscription fires. + await(payees.execute(bank::dto::AddPayee{.name = "Streamed Payee", .iban = "FR1420041010050500013M02606"}), + app.guiLoop()); REQUIRE(waitUntil([&] { return fired.load(); }, std::chrono::milliseconds{2000}, std::chrono::milliseconds{5}, app.guiLoop())); REQUIRE(newId > 0); From 2eee489b74c7631eea682971a51c6a24a4694932 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 27 Jul 2026 23:57:13 +0200 Subject: [PATCH 08/42] fix(bridge): prune subscriptions of destroyed handlers on publish publishResult already skipped an entry whose binding had expired, but only add/removeSubscription ever erased one. A handler destroyed without an explicit unsubscribe therefore left its entry behind until some other handler happened to subscribe -- which in a long-lived app with many transient handlers is never. Prune while already holding the lock and walking the list. Co-Authored-By: Claude Opus 5 (1M context) --- include/morph/core/bridge.hpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index b52be543..b249c159 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -407,6 +407,12 @@ class Bridge { std::vector, ::morph::exec::IExecutor*>> targets; { std::scoped_lock const lock{_subMtx}; + // Prune while we are already holding the lock and walking the list: + // a handler that is destroyed without unsubscribing would otherwise + // leave its entry behind until some *other* handler happened to call + // add/removeSubscription, which in a long-lived app with many + // transient handlers is never. + std::erase_if(_subscriptions, [](const InstanceSubscription& entry) { return entry.binding.expired(); }); for (const auto& entry : _subscriptions) { auto owner = entry.binding.lock(); if (owner && entry.type == type && owner->currentId.load() == mid.v && entry.sink) { From aa634d8b6c2de10850aaee543dfa5e325a6a1605 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 28 Jul 2026 00:01:16 +0200 Subject: [PATCH 09/42] fix(bridge): do not keep a stale instance id when an attach fails The default attachModel releases the current instance before acquiring the new one. If the acquire then failed -- a transport error, a server at maxLiveModels -- the binding kept pointing at the id it had just given up, so the next execute dispatched to a released instance and got a confusing "model not found" instead of the documented "handler not bound". Unbind first and publish the new id only on success. Co-Authored-By: Claude Opus 5 (1M context) --- include/morph/core/bridge.hpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index b249c159..6d2a61fd 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -289,9 +289,17 @@ class Bridge { return; } binding->contextKey = primary; + // The default `attachModel` releases the current instance before + // acquiring the new one. If the acquire then fails -- a transport error, + // a server at `maxLiveModels` -- the binding must not keep pointing at + // the id it just gave up, or the next execute dispatches to a released + // instance and gets a confusing "model not found" instead of the + // documented "handler not bound". Unbind first, publish only on success. + auto const previous = ::morph::exec::detail::ModelId{binding->currentId.load()}; + binding->currentId.store(0); + binding->primary.clear(); auto newId = loadBackend()->attachModel(binding->typeId, binding->modelFactory, - {.contextKey = binding->contextKey, .primary = primary}, - ::morph::exec::detail::ModelId{binding->currentId.load()}); + {.contextKey = binding->contextKey, .primary = primary}, previous); binding->primary = std::move(primary); binding->currentId.store(newId.v); } From f93ae516611cb972a7011833a40890d8754f57c5 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 28 Jul 2026 00:52:11 +0200 Subject: [PATCH 10/42] fix: clear clang-tidy on every line this branch changes - model_key.hpp: drop a redundant `typename`, justify the from_chars pointer pair, and wrap the two declaration macros in the same NOLINT(cppcoreguidelines-macro-usage) block registry.hpp already uses -- they must emit a template specialisation at global scope, which no function template can do. - test fixtures: annotate why model/action/result types need external linkage (glaze reflection cannot see into an anonymous namespace), mark const execute overloads [[nodiscard]], and take subscriber values by reference. - customer_model.cpp: a real latent bug moved verbatim from the old account_model.cpp -- QuerySingle's optional was dereferenced unchecked. requireUserId proves the row existed a moment earlier, which is not the same as proving this query found it; it now throws NotFound. Co-Authored-By: Claude Opus 5 (1M context) --- .../gui_wasm/src/models/customer_model_wasm.cpp | 3 +++ examples/bank/src/models/customer_model.cpp | 9 ++++++--- examples/bank/tests/test_payee.cpp | 2 +- examples/bank/tests/test_stateful_account.cpp | 4 ++-- include/morph/core/model_key.hpp | 17 +++++++++++++---- tests/test_example.cpp | 2 +- tests/test_shared_instances.cpp | 17 +++++++++++------ tests/test_subscription.cpp | 13 +++++++++---- 8 files changed, 46 insertions(+), 21 deletions(-) diff --git a/examples/bank/gui_wasm/src/models/customer_model_wasm.cpp b/examples/bank/gui_wasm/src/models/customer_model_wasm.cpp index 55a9b919..0c51fbd1 100644 --- a/examples/bank/gui_wasm/src/models/customer_model_wasm.cpp +++ b/examples/bank/gui_wasm/src/models/customer_model_wasm.cpp @@ -46,6 +46,8 @@ int defaultInterestBps(int kind) { return kind == static_cast(AccountKind:: } // namespace +// execute() is morph's dispatch contract; every model spells it as a member. +// NOLINTNEXTLINE(readability-convert-member-functions-to-static) dto::AccountInfo CustomerModel::execute(const dto::OpenAccount& action) { if (!action.validate()) { throw ValidationError{"invalid account kind/currency/overdraft"}; @@ -69,6 +71,7 @@ dto::AccountInfo CustomerModel::execute(const dto::OpenAccount& action) { return toInfo(rec, owner); } +// NOLINTNEXTLINE(readability-convert-member-functions-to-static) dto::AccountList CustomerModel::execute(const dto::ListAccounts& action) { const std::string owner = resolveOwner(action.owner); if (owner.empty()) { diff --git a/examples/bank/src/models/customer_model.cpp b/examples/bank/src/models/customer_model.cpp index e1be69be..0dae7f6a 100644 --- a/examples/bank/src/models/customer_model.cpp +++ b/examples/bank/src/models/customer_model.cpp @@ -68,11 +68,14 @@ dto::AccountList CustomerModel::execute(const dto::ListAccounts& action) { // than issuing a manual `WHERE user_id = ?` — the relation resolves the join // for us and returns the user's accounts directly. const auto userId = db::requireUserId(mapper(), owner); - auto user = mapper().QuerySingle(userId).value(); + auto user = mapper().QuerySingle(userId); + if (!user.has_value()) { + throw NotFound{"owner not found"}; + } dto::AccountList out; - out.accounts.reserve(user.accounts.Count()); - for (const auto& account : user.accounts.All()) { + out.accounts.reserve(user->accounts.Count()); + for (const auto& account : user->accounts.All()) { out.accounts.push_back(db::toAccountInfo(*account, owner)); } return out; diff --git a/examples/bank/tests/test_payee.cpp b/examples/bank/tests/test_payee.cpp index ca8cc36a..21267c91 100644 --- a/examples/bank/tests/test_payee.cpp +++ b/examples/bank/tests/test_payee.cpp @@ -66,7 +66,7 @@ TEST_CASE("PayeeModel notifies subscribers of the state it produces", "[payee][s // which action produced it. std::atomic fired{false}; std::int64_t newId = 0; - payees.subscribe([&](bank::dto::PayeeInfo info) { + payees.subscribe([&](const bank::dto::PayeeInfo& info) { newId = info.id; fired.store(true); }); diff --git a/examples/bank/tests/test_stateful_account.cpp b/examples/bank/tests/test_stateful_account.cpp index 857e1d71..328e9b96 100644 --- a/examples/bank/tests/test_stateful_account.cpp +++ b/examples/bank/tests/test_stateful_account.cpp @@ -63,8 +63,8 @@ TEST_CASE("two shared handlers on one account reach one instance", "[stateful-ac REQUIRE(await(screen.execute(bank::dto::GetAccount{.id = acct}), app.guiLoop()).id == acct); REQUIRE(await(sidebar.execute(bank::dto::GetAccount{.id = acct}), app.guiLoop()).id == acct); - REQUIRE(screen.primary().value() == acct); - REQUIRE(sidebar.primary().value() == acct); + REQUIRE(screen.primary().value_or(-1) == acct); + REQUIRE(sidebar.primary().value_or(-1) == acct); // The directory holds exactly one entry for the account both handlers named. REQUIRE(await(screen.instances(), app.guiLoop()) == std::vector{acct}); diff --git a/include/morph/core/model_key.hpp b/include/morph/core/model_key.hpp index 30f191d1..f51390e5 100644 --- a/include/morph/core/model_key.hpp +++ b/include/morph/core/model_key.hpp @@ -49,7 +49,7 @@ concept KeyedModel = requires { typename M::PrimaryKey; } && ModelKey -using PrimaryKeyOf = typename M::PrimaryKey; +using PrimaryKeyOf = M::PrimaryKey; /// @brief Encodes a primary key as its canonical wire string. /// @@ -83,9 +83,12 @@ template return std::string{text}; } else { K value{}; - const auto* const first = text.data(); - const auto* const last = first + text.size(); - auto [ptr, errc] = std::from_chars(first, last, value); + // from_chars is a [first, last) pointer API; a string_view's data()+size() + // is the only way to express its end, and is exactly what the standard + // intends here. + // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) + auto [ptr, errc] = std::from_chars(text.data(), text.data() + text.size(), value); + const auto* const last = text.data() + text.size(); // NOLINT(cppcoreguidelines-pro-bounds-pointer-arithmetic) if (errc != std::errc{} || ptr != last) { throw std::runtime_error("invalid primary key encoding: '" + std::string{text} + "'"); } @@ -123,6 +126,10 @@ concept ResultKeyed = ActionKeyTraits::hasKey && ActionKeyTraits::fromResu } // namespace morph::model +// NOLINTBEGIN(cppcoreguidelines-macro-usage) — declaration macros are the intended public API, +// matching BRIDGE_REGISTER_MODEL/ACTION in registry.hpp: they must emit a template +// specialisation at global scope, which no function template can do. + /// @brief Declares that action `A` carries its model's primary key in `MEMBER`. /// /// `MEMBER` is a pointer-to-data-member of `A` (e.g. `&GetAccount::id`) whose @@ -158,3 +165,5 @@ concept ResultKeyed = ActionKeyTraits::hasKey && ActionKeyTraits::fromResu return morph::model::keyToString(result.*MEMBER); \ } \ } + +// NOLINTEND(cppcoreguidelines-macro-usage) diff --git a/tests/test_example.cpp b/tests/test_example.cpp index 289217fe..4fa2b402 100644 --- a/tests/test_example.cpp +++ b/tests/test_example.cpp @@ -47,7 +47,7 @@ TEST_CASE("Example Model", "[model]") { REQUIRE(output.result == 6.0); fired.store(true); }); - handler.execute(ActionInput{1.0, 2.0, 3.0}); + handler.execute(ActionInput{.a = 1.0, .b = 2.0, .c = 3.0}); std::this_thread::sleep_for(std::chrono::milliseconds{50}); REQUIRE(fired.load() == true); diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp index b080b27a..5415a277 100644 --- a/tests/test_shared_instances.cpp +++ b/tests/test_shared_instances.cpp @@ -35,6 +35,10 @@ namespace { } // namespace /// A counter whose value lives *in the instance* — the whole point of keying. +// Model, action and result types need **external** linkage: glaze's +// plain-aggregate reflection cannot see into an anonymous namespace, and the +// BRIDGE_REGISTER_* macros specialise templates at global scope. +// NOLINTBEGIN(misc-use-internal-linkage) /// A stateless model would make every one of these tests vacuous. struct ShiCounterState { std::int64_t value = 0; @@ -63,8 +67,8 @@ struct ShiCounterModel { value += act.amount; return {.value = value}; } - ShiCounterState execute(const ShiRead& /*act*/) const { return {.value = value}; } - ShiCounterState execute(const ShiPeek& /*act*/) const { return {.value = value}; } + [[nodiscard]] ShiCounterState execute(const ShiRead& /*act*/) const { return {.value = value}; } + [[nodiscard]] ShiCounterState execute(const ShiPeek& /*act*/) const { return {.value = value}; } }; BRIDGE_REGISTER_MODEL(ShiCounterModel, "SHI_CounterModel") @@ -74,6 +78,7 @@ BRIDGE_REGISTER_ACTION(ShiCounterModel, ShiPeek, "SHI_Peek") BRIDGE_KEY_FROM(ShiAddTo, &ShiAddTo::id); BRIDGE_KEY_FROM(ShiRead, &ShiRead::id); +// NOLINTEND(misc-use-internal-linkage) namespace { @@ -125,8 +130,8 @@ TEST_CASE("two AllowShared handlers naming one key reach one instance", "[shared REQUIRE(settle(second.execute(ShiAddTo{.id = 42, .amount = 5})).value == 15); REQUIRE(settle(first.execute(ShiRead{.id = 42})).value == 15); - REQUIRE(first.primary().value() == 42); - REQUIRE(second.primary().value() == 42); + REQUIRE(first.primary().value_or(-1) == 42); + REQUIRE(second.primary().value_or(-1) == 42); } TEST_CASE("a plain handler never joins the directory", "[shared-instances]") { @@ -173,7 +178,7 @@ TEST_CASE("a keyed action re-points the handler rather than re-keying the instan // The primary is deliberately not write-once: naming another key moves the // *handler*, leaving instance 100 and its state intact for `pinned`. settle(mover.execute(ShiAddTo{.id = 200, .amount = 1})); - REQUIRE(mover.primary().value() == 200); + REQUIRE(mover.primary().value_or(-1) == 200); REQUIRE(settle(pinned.execute(ShiPeek{})).value == 50); } @@ -240,7 +245,7 @@ TEST_CASE("explicit attach binds without executing an action", "[shared-instance REQUIRE_FALSE(handler.primary().has_value()); handler.attach(64); - REQUIRE(handler.primary().value() == 64); + REQUIRE(handler.primary().value_or(-1) == 64); // A keyless action now has an instance to run against. REQUIRE(settle(handler.execute(ShiPeek{})).value == 0); } diff --git a/tests/test_subscription.cpp b/tests/test_subscription.cpp index 0e64115e..12455fce 100644 --- a/tests/test_subscription.cpp +++ b/tests/test_subscription.cpp @@ -29,6 +29,10 @@ // ── Fixture: a stateful counter, so a subscription reports real shared state ── /// The state type subscribers name. Produced by more than one action, which is +// Model, action and result types need **external** linkage: glaze's +// plain-aggregate reflection cannot see into an anonymous namespace, and the +// BRIDGE_REGISTER_* macros specialise templates at global scope. +// NOLINTBEGIN(misc-use-internal-linkage) /// exactly the case result-keyed subscription exists to serve. struct SubCounterState { std::int64_t value = 0; @@ -65,8 +69,8 @@ struct SubCounterModel { value += act.by; return {.value = value}; } - SubCounterState execute(const SubRead& /*act*/) const { return {.value = value}; } - SubLabelState execute(const SubLabel& /*act*/) const { return {.text = "label"}; } + [[nodiscard]] SubCounterState execute(const SubRead& /*act*/) const { return {.value = value}; } + [[nodiscard]] static SubLabelState execute(const SubLabel& /*act*/) { return {.text = "label"}; } static SubCounterState execute(const SubExplode& /*act*/) { throw std::runtime_error{"boom"}; } }; @@ -80,6 +84,7 @@ BRIDGE_KEY_FROM(SubBump, &SubBump::id); BRIDGE_KEY_FROM(SubRead, &SubRead::id); BRIDGE_KEY_FROM(SubLabel, &SubLabel::id); BRIDGE_KEY_FROM(SubExplode, &SubExplode::id); +// NOLINTEND(misc-use-internal-linkage) namespace { @@ -91,7 +96,7 @@ using morph::bridge::BridgeHandler; template void drain(morph::async::Completion comp) { auto done = std::make_shared>(false); - std::move(comp).then([done](T) { done->store(true); }).onError([done](const std::exception_ptr&) { + std::move(comp).then([done](const T&) { done->store(true); }).onError([done](const std::exception_ptr&) { done->store(true); }); REQUIRE(morph::testing::waitUntil([&] { return done->load(); })); @@ -183,7 +188,7 @@ TEST_CASE("distinct result types do not interfere", "[bridge][subscription]") { int counters = 0; int labels = 0; handler.subscribe([&](SubCounterState) { ++counters; }); - handler.subscribe([&](SubLabelState) { ++labels; }); + handler.subscribe([&](const SubLabelState&) { ++labels; }); drain(handler.execute(SubBump{.id = 40, .by = 1})); REQUIRE(counters == 1); From 8d7390452f07c955eebf8e16f4cf8062d166c841 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 28 Jul 2026 00:53:46 +0200 Subject: [PATCH 11/42] docs(bridge): state that subscription fan-out is per Bridge shared_instances.md is explicit that an instance is shared across clients, so a reader could reasonably assume a result produced by another client on that instance reaches this client's subscribers. It does not: there is no server-initiated frame. Say so where the subscription semantics are defined, not only in todo.md. Co-Authored-By: Claude Opus 5 (1M context) --- docs/spec/core/bridge.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/spec/core/bridge.md b/docs/spec/core/bridge.md index 162e5a0a..3fcc749b 100644 --- a/docs/spec/core/bridge.md +++ b/docs/spec/core/bridge.md @@ -380,6 +380,13 @@ existing subscriber. - **Only copy-constructible results are published.** A result type that cannot be copied is delivered to its caller's `Completion` as usual but is never boxed for subscribers. +- **Fan-out is per `Bridge`, i.e. per client process.** This is the one place + subscriptions are narrower than instance sharing: an instance really is shared + across clients ([shared_instances.md](shared_instances.md)), but a result + produced by *another client* on that instance does not reach this client's + subscribers — there is no server-initiated frame, and both transports would + need an unsolicited-message path to carry one. Two handlers in one process see + each other's work; two clients do not, and must re-read to notice a change. ## Thread safety From 38b6e2d8f49f21ba49d554db9943bbf1698a4a65 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 28 Jul 2026 01:06:20 +0200 Subject: [PATCH 12/42] fix: keep the no-subscriber path free, and de-flake the switchBackend race Two things the Valgrind job surfaced. publishResult ran on every successful result, building a std::type_index and copying the result into a std::any before the Completion could resolve -- work paid by every existing caller for a feature they are not using. A relaxed atomic subscription count now short-circuits it. Measured over 200k executes: 537k/s before, ~548k/s on master, i.e. back to parity. The concurrency test's `succeeded > 0` was a coin flip, not an invariant. Valgrind serialises every thread onto one core, so a switcher looping every 1ms can legitimately cancel all 200 in-flight calls and leave zero successes; master passed it by luck. The structural claim it was making -- that the snapshot-and-dispatch path still resolves successfully after repeated switching -- is now asserted against the quiesced bridge, which tests the same property without racing the switcher. The churn assertions (every call resolves, none is lost) are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- include/morph/core/bridge.hpp | 23 ++++++++++++++++++++++- tests/test_concurrency_invariants.cpp | 17 ++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index 6d2a61fd..1b93c137 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -381,6 +381,7 @@ class Bridge { } } _subscriptions.push_back({.binding = binding, .type = type, .sink = std::move(sink), .exec = exec}); + _subscriptionCount.store(_subscriptions.size(), std::memory_order_relaxed); } /// @brief Removes @p binding's subscription for @p type, if any. @@ -392,6 +393,21 @@ class Bridge { auto owner = entry.binding.lock(); return !owner || (owner.get() == binding.get() && entry.type == type); }); + _subscriptionCount.store(_subscriptions.size(), std::memory_order_relaxed); + } + + /// @brief Whether any subscription is currently registered on this bridge. + /// + /// A single relaxed atomic load, so the overwhelmingly common case — a + /// process with no subscribers at all — pays nothing per result. Without + /// this, every successful action would build a `std::type_index`, copy its + /// result into a `std::any`, take `_subMtx` and walk the (empty) + /// subscription list before its `Completion` could resolve: a throughput + /// regression for every existing caller, on the hot path, to serve a feature + /// they are not using. + /// @return `true` if at least one subscription exists. + [[nodiscard]] bool hasSubscribers() const noexcept { + return _subscriptionCount.load(std::memory_order_relaxed) != 0U; } /// @brief Delivers @p value to every subscriber attached to instance @p mid. @@ -421,6 +437,7 @@ class Bridge { // add/removeSubscription, which in a long-lived app with many // transient handlers is never. std::erase_if(_subscriptions, [](const InstanceSubscription& entry) { return entry.binding.expired(); }); + _subscriptionCount.store(_subscriptions.size(), std::memory_order_relaxed); for (const auto& entry : _subscriptions) { auto owner = entry.binding.lock(); if (owner && entry.type == type && owner->currentId.load() == mid.v && entry.sink) { @@ -705,7 +722,7 @@ class Bridge { // bridge's liveness token: a completion can in principle // resolve after the Bridge is gone. if constexpr (std::is_copy_constructible_v) { - if (!alive.expired()) { + if (hasSubscribers() && !alive.expired()) { publishResult(::morph::exec::detail::ModelId{raw}, std::type_index{typeid(R)}, std::any{*typedResult}); } @@ -797,6 +814,10 @@ class Bridge { }; std::mutex _subMtx; std::vector _subscriptions; + // Mirrors _subscriptions.size() for the lock-free hasSubscribers() probe. + // Maintained under _subMtx; read relaxed off it. A stale-by-one read is + // harmless: publishResult re-checks under the lock and finds nothing. + std::atomic _subscriptionCount{0}; // Destroyed with the Bridge; handlers hold weak_ptrs to it (see liveness()). std::shared_ptr _liveness{std::make_shared()}; }; diff --git a/tests/test_concurrency_invariants.cpp b/tests/test_concurrency_invariants.cpp index 70c8352e..e6e06224 100644 --- a/tests/test_concurrency_invariants.cpp +++ b/tests/test_concurrency_invariants.cpp @@ -302,9 +302,20 @@ TEST_CASE("morph::bridge::Bridge: concurrent executeVia under repeated switchBac REQUIRE(resolved.load() == totalActions); REQUIRE(succeeded.load() + failed.load() == totalActions); - // Some actions must succeed — if every single one failed something is - // structurally broken (the snapshot-and-dispatch path never resolved). - REQUIRE(succeeded.load() > 0); + + // The structural claim is that the snapshot-and-dispatch path still + // *resolves successfully* after repeated backend switching — not that it + // wins any particular race. Assert it against the now-quiesced bridge + // rather than against the churn above: under a thread-serialising tool + // (Valgrind runs every thread on one core) the switcher can legitimately + // cancel all 200 in-flight calls, which made a `succeeded > 0` check on the + // churn a coin flip rather than an invariant. + std::atomic afterSwitching{0}; + constexpr int settledActions = 5; + for (int idx = 0; idx < settledActions; ++idx) { + handler.execute(LoadCountAction{1}).then([&](int) { afterSwitching.fetch_add(1); }); + } + REQUIRE(waitUntil([&] { return afterSwitching.load() == settledActions; }, 10s)); } // ── morph::offline::NetworkMonitor: stop() called from onOnline does not deadlock ───────────── From bd4d2713353e169939d163e3ad03a3fa58d62483 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 28 Jul 2026 01:10:32 +0200 Subject: [PATCH 13/42] test: cover result-sourced primary keys, which shipped untested BRIDGE_KEY_FROM_RESULT and the assignPrimary promotion it drives had no test at all -- a real gap, not just a coverage number. The two new cases pin the property that motivated promoting an instance in place rather than re-pointing to a fresh one: the state the creating action just built is still there afterwards, and the instance is then reachable by its generated key like any other. Covered locally and over SimulatedRemoteBackend, so the `assign` wire verb is exercised too. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_shared_instances.cpp | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp index 5415a277..87f508c5 100644 --- a/tests/test_shared_instances.cpp +++ b/tests/test_shared_instances.cpp @@ -58,6 +58,17 @@ struct ShiPeek { int unused = 0; }; +/// Creates the entity, so its key cannot be in the request — it comes back in +/// the reply, exactly as a database insert returns its generated primary key. +struct ShiCreate { + std::int64_t initial = 0; +}; + +struct ShiCreated { + std::int64_t id = 0; + std::int64_t value = 0; +}; + struct ShiCounterModel { using PrimaryKey = std::int64_t; @@ -69,15 +80,23 @@ struct ShiCounterModel { } [[nodiscard]] ShiCounterState execute(const ShiRead& /*act*/) const { return {.value = value}; } [[nodiscard]] ShiCounterState execute(const ShiPeek& /*act*/) const { return {.value = value}; } + + ShiCreated execute(const ShiCreate& act) { + static std::atomic nextId{9000}; + value = act.initial; + return {.id = nextId.fetch_add(1), .value = value}; + } }; BRIDGE_REGISTER_MODEL(ShiCounterModel, "SHI_CounterModel") BRIDGE_REGISTER_ACTION(ShiCounterModel, ShiAddTo, "SHI_AddTo") BRIDGE_REGISTER_ACTION(ShiCounterModel, ShiRead, "SHI_Read") BRIDGE_REGISTER_ACTION(ShiCounterModel, ShiPeek, "SHI_Peek") +BRIDGE_REGISTER_ACTION(ShiCounterModel, ShiCreate, "SHI_Create") BRIDGE_KEY_FROM(ShiAddTo, &ShiAddTo::id); BRIDGE_KEY_FROM(ShiRead, &ShiRead::id); +BRIDGE_KEY_FROM_RESULT(ShiCreate, &ShiCreated::id); // NOLINTEND(misc-use-internal-linkage) namespace { @@ -296,6 +315,42 @@ TEST_CASE("a remote plain handler still gets its own instance", "[shared-instanc REQUIRE(settle(shared.execute(ShiRead{.id = 8})).value == 30); } +TEST_CASE("a result-sourced key promotes the instance the create ran on", "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + + BridgeHandler creator{bridge, &exec}; + auto created = settle(creator.execute(ShiCreate{.initial = 5})); + + // The handler adopts the generated key before any user callback sees the + // result, so a .then() could immediately act on the new instance. + REQUIRE(created.id != 0); + REQUIRE(creator.primary().value_or(-1) == created.id); + + // Crucially the *same* instance was filed under that key rather than a fresh + // one being created for it: everything the create did is still there. This + // is the whole reason promotion exists instead of re-pointing. + REQUIRE(settle(creator.execute(ShiPeek{})).value == 5); + + // …and it is now reachable by key like any other shared instance. + BridgeHandler latecomer{bridge, &exec}; + latecomer.attach(created.id); + REQUIRE(settle(latecomer.execute(ShiPeek{})).value == 5); + REQUIRE(settle(latecomer.instances()).size() == 1); +} + +TEST_CASE("a result-sourced key promotes across a remote backend", "[shared-instances]") { + morph::testing::InlineExecutor exec; + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool); + Bridge bridge{std::make_unique(*server)}; + + BridgeHandler creator{bridge, &exec}; + auto created = settle(creator.execute(ShiCreate{.initial = 11})); + REQUIRE(creator.primary().value_or(-1) == created.id); + REQUIRE(settle(creator.execute(ShiPeek{})).value == 11); +} + TEST_CASE("primary keys round-trip through their canonical encoding", "[shared-instances]") { REQUIRE(morph::model::keyToString(-17) == "-17"); REQUIRE(morph::model::keyFromString("-17") == -17); From 2bd38d1773c3bcfdbc2974275540040e4dabaa8c Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 28 Jul 2026 01:14:45 +0200 Subject: [PATCH 14/42] test(net): cover keyed instance sharing over a real socket SocketBackend's registerModelShared/attachModel/listInstances shipped exercised only through SimulatedRemoteBackend, which never touches them -- the wire path for sharing had no coverage at all. Two cases over an actual WebSocket, using a stateful counter because a stateless echo model cannot tell sharing from not-sharing: two separate clients naming one key observe a single counter (15, not 5) and one directory entry, while a plain handler on a third connection registers its own instance and counts from zero. Co-Authored-By: Claude Opus 5 (1M context) --- tests/net/test_socket_backend.cpp | 97 +++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/tests/net/test_socket_backend.cpp b/tests/net/test_socket_backend.cpp index e8c8bcd6..9829182c 100644 --- a/tests/net/test_socket_backend.cpp +++ b/tests/net/test_socket_backend.cpp @@ -34,6 +34,35 @@ BRIDGE_REGISTER_MODEL(SbEchoModel, "SbEchoModel") BRIDGE_REGISTER_ACTION(SbEchoModel, SbEchoAction, "SbEchoAction") BRIDGE_REGISTER_ACTION(SbEchoModel, SbEchoFail, "SbEchoFail") +// A stateful, keyed model: two clients naming the same key must reach one +// instance and see one counter. A stateless echo model could not tell the +// difference between sharing and not sharing. +// +// External linkage as above: glaze reflection and the BRIDGE_REGISTER_* macros +// both require it. +// NOLINTBEGIN(misc-use-internal-linkage) +struct SbBump { + std::int64_t id = 0; + int by = 0; +}; +struct SbTotal { + int value = 0; +}; + +struct SbCounterModel { + using PrimaryKey = std::int64_t; + int value = 0; + SbTotal execute(const SbBump& act) { + value += act.by; + return {.value = value}; + } +}; + +BRIDGE_REGISTER_MODEL(SbCounterModel, "SbCounterModel") +BRIDGE_REGISTER_ACTION(SbCounterModel, SbBump, "SbBump") +BRIDGE_KEY_FROM(SbBump, &SbBump::id); +// NOLINTEND(misc-use-internal-linkage) + struct SbSlowAction { int value = 0; }; @@ -164,6 +193,74 @@ TEST_CASE("SocketBackend: two backends share one server with isolated model stat REQUIRE(lastB.load() == 22); } +TEST_CASE("SocketBackend: two clients sharing a key reach one instance over the wire", + "[net][socket_backend][shared-instances]") { + morph::exec::ThreadPoolExecutor serverPool{2}; + auto server = std::make_shared(serverPool); + morph::net::SocketServer wsServer{*server, 0}; + REQUIRE(wsServer.listen()); + + std::string const url = "ws://127.0.0.1:" + std::to_string(static_cast(wsServer.port())); + auto backendA = std::make_unique(url); + auto backendB = std::make_unique(url); + REQUIRE(backendA->waitForConnected()); + REQUIRE(backendB->waitForConnected()); + + morph::exec::ThreadPoolExecutor cbPool{2}; + morph::bridge::Bridge bridgeA{std::move(backendA)}; + morph::bridge::Bridge bridgeB{std::move(backendB)}; + morph::bridge::BridgeHandler fromA{bridgeA, &cbPool}; + morph::bridge::BridgeHandler fromB{bridgeB, &cbPool}; + + // Two genuinely separate clients, two sockets, one server-side directory. + std::atomic lastA{-1}; + fromA.execute(SbBump{.id = 77, .by = 10}).then([&](const SbTotal& res) { lastA.store(res.value); }).onError([](const std::exception_ptr&) {}); + spinUntil([&] { return lastA.load() != -1; }); + REQUIRE(lastA.load() == 10); + + std::atomic lastB{-1}; + fromB.execute(SbBump{.id = 77, .by = 5}).then([&](const SbTotal& res) { lastB.store(res.value); }).onError([](const std::exception_ptr&) {}); + spinUntil([&] { return lastB.load() != -1; }); + // 15, not 5: the second client attached to the first client's instance. + REQUIRE(lastB.load() == 15); + + std::atomic keyCount{-1}; + fromB.instances().then([&](const std::vector& keys) { keyCount.store(static_cast(keys.size())); }) + .onError([](const std::exception_ptr&) {}); + spinUntil([&] { return keyCount.load() != -1; }); + REQUIRE(keyCount.load() == 1); +} + +TEST_CASE("SocketBackend: a plain handler keeps its own instance over the wire", "[net][socket_backend]") { + morph::exec::ThreadPoolExecutor serverPool{2}; + auto server = std::make_shared(serverPool); + morph::net::SocketServer wsServer{*server, 0}; + REQUIRE(wsServer.listen()); + + std::string const url = "ws://127.0.0.1:" + std::to_string(static_cast(wsServer.port())); + auto shared = std::make_unique(url); + auto priv = std::make_unique(url); + REQUIRE(shared->waitForConnected()); + REQUIRE(priv->waitForConnected()); + + morph::exec::ThreadPoolExecutor cbPool{2}; + morph::bridge::Bridge sharedBridge{std::move(shared)}; + morph::bridge::Bridge privBridge{std::move(priv)}; + morph::bridge::BridgeHandler joined{sharedBridge, &cbPool}; + morph::bridge::BridgeHandler alone{privBridge, &cbPool}; + + std::atomic lastShared{-1}; + joined.execute(SbBump{.id = 88, .by = 30}).then([&](const SbTotal& res) { lastShared.store(res.value); }).onError([](const std::exception_ptr&) {}); + spinUntil([&] { return lastShared.load() != -1; }); + REQUIRE(lastShared.load() == 30); + + std::atomic lastPriv{-1}; + alone.execute(SbBump{.id = 88, .by = 1}).then([&](const SbTotal& res) { lastPriv.store(res.value); }).onError([](const std::exception_ptr&) {}); + spinUntil([&] { return lastPriv.load() != -1; }); + // Opted out, so it registered its own instance and counts from zero. + REQUIRE(lastPriv.load() == 1); +} + TEST_CASE("SocketBackend: registerModel on a never-connected socket throws, does not hang", "[net][socket_backend][disconnect]") { // Port 1 is reserved (root-only) on Linux/macOS and never listening — the From 8131cb341b0be6b84a60a9d14f75131bb459df47 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 28 Jul 2026 01:19:56 +0200 Subject: [PATCH 15/42] docs: record the stateful-models program in the changelog The Unreleased section had no Changed or Removed headings because nothing had needed them yet. This program needs both: A7's closeConnection semantics changed, and the reactive-draft mechanism is a public API removal. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23959c69..2fafd62a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,9 +32,54 @@ API surface). (`scripts/check_deprecated_markers.sh`) enforcing that every `[[deprecated("...")]]` marker names a replacement and a target removal version. +- **Keyed, shareable model instances.** A model declares a nested `PrimaryKey` + alias; actions declare which field carries it (`BRIDGE_KEY_FROM`) or that + their result establishes it (`BRIDGE_KEY_FROM_RESULT`). + `BridgeHandler` joins a server-side directory keyed on + `(typeId, primary)`, so handlers in one process — or in two clients over one + `RemoteServer` — reach the same instance. Adds `attach()`, `primary()` and + `instances()` to the handler, the `primary`/`shared` envelope fields and the + `attach`/`assign`/`instances` wire kinds, all additive. See + `docs/spec/core/shared_instances.md`. +- **Instance subscriptions.** `BridgeHandler::subscribe(cb)`, keyed on the + result/state type, fires whenever an `R` is produced on the instance the + handler is attached to — by any handler attached to it. Fan-out is per + `Bridge`; there is no server-initiated push. See + `docs/spec/core/bridge.md#subscription-semantics`. + +### Changed + +- **`RemoteServer::closeConnection` now releases one reference per attachment + rather than erasing every model in the scope.** Required by cross-client + instance sharing: otherwise one client's disconnect destroys an instance + another client is still attached to. A connection scope records an + attachment *count* per instance for the same reason. Unshared instances have + exactly one attacher, so their lifetime is unchanged. +- `examples/bank` is reshaped onto stateful, keyed models: `AccountModel` holds + one account in memory keyed by account id, and the new `CustomerModel` takes + the per-owner half (`ListAccounts`/`OpenAccount`). + +### Removed + +- **The reactive-draft mechanism** — `BridgeHandler::set<&A::field>`, + `reset`, the action-keyed `subscribe`, and their in-flight coalescing. + Its job is done better by a stateful model holding the draft itself, and + `subscribe` now means instance subscriptions (above). `morph::flows::FlowSession` + already owned its own draft tuple and now gates on `ActionValidator` and + dispatches directly; its public API, the `w-*`/`app-*` schema and + `WizardView.qml` are unchanged. `ActionValidator` keeps its server-side + validation role and loses only its draft-readiness one. Pre-1.0, per + `docs/spec/VERSIONING.md`. ### Fixed +- `CustomerModel::execute(ListAccounts)` dereferenced `QuerySingle`'s optional + unchecked (inherited verbatim from the old `AccountModel`); it now throws + `NotFound`. +- A flaky assertion in `tests/test_concurrency_invariants.cpp`: `succeeded > 0` + during backend churn is a scheduling race, not an invariant — under a + thread-serialising tool the switcher can cancel every in-flight call. The + same structural property is now asserted against the quiesced bridge. - Stale pre-JSON "5-part"/"6-part protocol" wording in `docs/ARCHITECTURE.md` and a test comment — the wire has been a JSON `Envelope` since it superseded the pipe-delimited protocol. From 8ab6bcab7741c44141372d08c227efa8f0dedb32 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 28 Jul 2026 01:20:44 +0200 Subject: [PATCH 16/42] docs(shared_instances): document the assign verb, correct the change count The wire-protocol section listed three additive changes and omitted `assign` entirely -- the verb that makes a result-sourced key promote in place instead of stranding whatever the creating action just did. Co-Authored-By: Claude Opus 5 (1M context) --- docs/spec/core/shared_instances.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/spec/core/shared_instances.md b/docs/spec/core/shared_instances.md index bb49cae2..15ce382b 100644 --- a/docs/spec/core/shared_instances.md +++ b/docs/spec/core/shared_instances.md @@ -203,7 +203,7 @@ construction. ## Wire protocol changes -Three additive changes. All are compatible with the additive-only evolution +Four additive changes. All are compatible with the additive-only evolution policy in [wire.md](wire.md), and the lenient decoding that A6 established means an older peer ignores what it does not understand. @@ -218,6 +218,13 @@ established means an older peer ignores what it does not understand. `ModelId`. Semantically a `deregister` + `register` pair, made atomic so a re-pointing handler cannot lose its slot to `LimitPolicy::maxLiveModels` in between. +- **A new `assign` request.** Files an already-live `modelId` under a primary + key, in place. This is what makes a result-sourced key work without losing + state: an action that creates its own entity runs on a not-yet-keyed + instance, and only the reply carries the generated key, so the instance the + action ran on is promoted rather than abandoned for a fresh one. The existing + holder of a key always wins — promoting onto a taken key is a silent no-op, + never a displacement. - **A new `instances` request.** Takes a model type id, replies with the live primary keys for it. Subject to `authorize` like any other request; see below. @@ -236,7 +243,7 @@ policy being `ownerPrincipal.empty() || ownerPrincipal == ctx.principal` Under that policy, a second client attaching to an instance the first client created would be **rejected**. Cross-client sharing and per-instance ownership -are therefore mutually exclusive, and the design must say so rather than let an +are therefore mutually exclusive, and the design says so rather than letting an authorizer silently defeat the feature: - **A shared instance is ownerless.** Its `ownerPrincipal` is empty, so the From e304cadd9134c2ebce10e44088d403322df03aa9 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 28 Jul 2026 01:29:35 +0200 Subject: [PATCH 17/42] test: cover the directory's collision and no-op paths Three branches the happy-path tests never reached, all driven through the public API rather than a test-only accessor: - a creating action whose generated key collides with a live instance must leave the incumbent's state and directory entry alone (ShiCreateAs lets a test choose the generated id, which is the only way to force this); - re-attaching to the key a handler already holds must not release and re-acquire the instance, which would silently discard its state; - instances() before anything is attached is empty, not an error. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_shared_instances.cpp | 53 +++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp index 87f508c5..35259d4d 100644 --- a/tests/test_shared_instances.cpp +++ b/tests/test_shared_instances.cpp @@ -69,6 +69,13 @@ struct ShiCreated { std::int64_t value = 0; }; +/// Like ShiCreate, but the caller picks the id the reply will carry — the only +/// way to force a key collision through the public API. +struct ShiCreateAs { + std::int64_t wantId = 0; + std::int64_t initial = 0; +}; + struct ShiCounterModel { using PrimaryKey = std::int64_t; @@ -86,6 +93,11 @@ struct ShiCounterModel { value = act.initial; return {.id = nextId.fetch_add(1), .value = value}; } + + ShiCreated execute(const ShiCreateAs& act) { + value = act.initial; + return {.id = act.wantId, .value = value}; + } }; BRIDGE_REGISTER_MODEL(ShiCounterModel, "SHI_CounterModel") @@ -93,10 +105,12 @@ BRIDGE_REGISTER_ACTION(ShiCounterModel, ShiAddTo, "SHI_AddTo") BRIDGE_REGISTER_ACTION(ShiCounterModel, ShiRead, "SHI_Read") BRIDGE_REGISTER_ACTION(ShiCounterModel, ShiPeek, "SHI_Peek") BRIDGE_REGISTER_ACTION(ShiCounterModel, ShiCreate, "SHI_Create") +BRIDGE_REGISTER_ACTION(ShiCounterModel, ShiCreateAs, "SHI_CreateAs") BRIDGE_KEY_FROM(ShiAddTo, &ShiAddTo::id); BRIDGE_KEY_FROM(ShiRead, &ShiRead::id); BRIDGE_KEY_FROM_RESULT(ShiCreate, &ShiCreated::id); +BRIDGE_KEY_FROM_RESULT(ShiCreateAs, &ShiCreated::id); // NOLINTEND(misc-use-internal-linkage) namespace { @@ -351,6 +365,45 @@ TEST_CASE("a result-sourced key promotes across a remote backend", "[shared-inst REQUIRE(settle(creator.execute(ShiPeek{})).value == 11); } +TEST_CASE("promoting onto a key another instance holds leaves the incumbent alone", "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + + // Hold key 4242 with a real instance carrying state. + BridgeHandler incumbent{bridge, &exec}; + settle(incumbent.execute(ShiAddTo{.id = 4242, .amount = 99})); + + // A creating action whose generated key collides with it must not displace + // the incumbent: the existing holder always wins, so no handler silently + // ends up pointing at a different instance than the one it created. + BridgeHandler collider{bridge, &exec}; + settle(collider.execute(ShiCreateAs{.wantId = 4242, .initial = 7})); + + REQUIRE(settle(incumbent.execute(ShiPeek{})).value == 99); + REQUIRE(settle(incumbent.instances()) == std::vector{4242}); +} + +TEST_CASE("attaching to the key a handler already holds is a no-op", "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + + BridgeHandler handler{bridge, &exec}; + settle(handler.execute(ShiAddTo{.id = 55, .amount = 8})); + + // Re-attaching to the same key must not release and re-acquire the + // instance, which would silently discard its state. + handler.attach(55); + handler.attach(55); + REQUIRE(settle(handler.execute(ShiPeek{})).value == 8); +} + +TEST_CASE("instances() is empty before anything is attached", "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + BridgeHandler handler{bridge, &exec}; + REQUIRE(settle(handler.instances()).empty()); +} + TEST_CASE("primary keys round-trip through their canonical encoding", "[shared-instances]") { REQUIRE(morph::model::keyToString(-17) == "-17"); REQUIRE(morph::model::keyFromString("-17") == -17); From b7da59392bb540b56768a206f7b6bfc29e3906e8 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 28 Jul 2026 01:58:15 +0200 Subject: [PATCH 18/42] test(remote): cover the server side of the shared instance directory Patch coverage showed the new wire surface's server paths were reachable only through the happy path: 47 of the uncovered lines were in remote.hpp. These drive RemoteServer directly, which is the only way to reach the refcount-and-scope interactions that make cross-client sharing safe. Covers the A7 change concretely -- closing one of two connections sharing a key leaves the instance alive and executable for the other, and only the last release destroys it -- plus attach re-pointing, assign promotion, assign declining to displace an incumbent, the instances listing (with a private register correctly absent), empty-typeId rejection for all three new kinds, and a shared register against an already-closed scope. The directory keys on envelope strings, so no keyed model type is needed to exercise it -- that is a client-side concept. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_remote_connection_scope.cpp | 200 +++++++++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/tests/test_remote_connection_scope.cpp b/tests/test_remote_connection_scope.cpp index 4b8dc7bf..da58f747 100644 --- a/tests/test_remote_connection_scope.cpp +++ b/tests/test_remote_connection_scope.cpp @@ -470,3 +470,203 @@ TEST_CASE("morph::backend::RemoteServer: repeated registers on a closed scope ne } REQUIRE(server->health().liveModels == 0U); } + +// ── Shared instance directory: the server side of keyed instances ──────────── +// +// The directory keys on the `(typeId, primary)` strings the envelope carries, +// so it needs no keyed model type to exercise — that is a client-side concept. +// These drive RemoteServer directly, which is the only way to reach the +// refcount-and-scope interactions that make cross-client sharing safe. + +TEST_CASE("morph::backend::RemoteServer: two connections sharing a key reach one instance", + "[remote][connection-scope][shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto& env = csEnv(); + auto server = std::make_shared(pool, env.dispatcher, env.registry); + + auto cidA = server->openConnection(); + auto cidB = server->openConnection(); + + WaitReply regA; + server->handle(morph::wire::encode(morph::wire::makeRegisterShared("CS_SquareModel", "42")), std::ref(regA), cidA); + REQUIRE(regA.await()); + REQUIRE(regA.env.kind == "ok"); + + WaitReply regB; + server->handle(morph::wire::encode(morph::wire::makeRegisterShared("CS_SquareModel", "42")), std::ref(regB), cidB); + REQUIRE(regB.await()); + REQUIRE(regB.env.kind == "ok"); + + // One instance, not two: the second register attached to the first's. + REQUIRE(regB.env.modelId == regA.env.modelId); + REQUIRE(server->health().liveModels == 1U); + + // Closing one connection releases only *its* reference — the instance must + // survive for the connection still attached. This is the A7 change. + server->closeConnection(cidA); + REQUIRE(server->health().liveModels == 1U); + + morph::wire::Envelope execReq; + execReq.kind = "execute"; + execReq.modelId = regA.env.modelId; + execReq.modelType = "CS_SquareModel"; + execReq.actionType = "CS_SquareAction"; + execReq.body = R"({"x":5})"; + WaitReply stillThere; + server->handle(morph::wire::encode(execReq), std::ref(stillThere)); + REQUIRE(stillThere.await()); + REQUIRE(stillThere.env.kind == "ok"); + REQUIRE(stillThere.env.body == "25"); + + // The last reference goes, and so does the instance. + server->closeConnection(cidB); + REQUIRE(server->health().liveModels == 0U); +} + +TEST_CASE("morph::backend::RemoteServer: instances lists live shared keys", + "[remote][connection-scope][shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto& env = csEnv(); + auto server = std::make_shared(pool, env.dispatcher, env.registry); + auto cid = server->openConnection(); + + // Unrolled rather than looped: each Catch2 REQUIRE expands to branches, and + // a loop around them trips the cognitive-complexity gate for no benefit. + WaitReply regSeven; + server->handle(morph::wire::encode(morph::wire::makeRegisterShared("CS_SquareModel", "7")), std::ref(regSeven), + cid); + REQUIRE(regSeven.await()); + REQUIRE(regSeven.env.kind == "ok"); + + WaitReply regNine; + server->handle(morph::wire::encode(morph::wire::makeRegisterShared("CS_SquareModel", "9")), std::ref(regNine), cid); + REQUIRE(regNine.await()); + REQUIRE(regNine.env.kind == "ok"); + + // A private register is invisible to the directory by construction. + WaitReply priv; + server->handle(morph::wire::encode(morph::wire::makeRegister("CS_SquareModel")), std::ref(priv), cid); + REQUIRE(priv.await()); + + WaitReply listed; + server->handle(morph::wire::encode(morph::wire::makeInstances("CS_SquareModel")), std::ref(listed), cid); + REQUIRE(listed.await()); + REQUIRE(listed.env.kind == "ok"); + std::vector keys; + REQUIRE_FALSE(glz::read_json(keys, listed.env.body)); + std::ranges::sort(keys); + REQUIRE(keys == std::vector{"7", "9"}); +} + +TEST_CASE("morph::backend::RemoteServer: attach re-points and releases the old instance", + "[remote][connection-scope][shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto& env = csEnv(); + auto server = std::make_shared(pool, env.dispatcher, env.registry); + auto cid = server->openConnection(); + + WaitReply first; + server->handle(morph::wire::encode(morph::wire::makeRegisterShared("CS_SquareModel", "1")), std::ref(first), cid); + REQUIRE(first.await()); + REQUIRE(first.env.kind == "ok"); + REQUIRE(server->health().liveModels == 1U); + + WaitReply moved; + server->handle(morph::wire::encode(morph::wire::makeAttach("CS_SquareModel", "2", first.env.modelId)), + std::ref(moved), cid); + REQUIRE(moved.await()); + REQUIRE(moved.env.kind == "ok"); + REQUIRE(moved.env.modelId != first.env.modelId); + // Nobody else held key 1, so re-pointing destroyed it rather than leaking. + REQUIRE(server->health().liveModels == 1U); +} + +TEST_CASE("morph::backend::RemoteServer: assign files a live instance under a key", + "[remote][connection-scope][shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto& env = csEnv(); + auto server = std::make_shared(pool, env.dispatcher, env.registry); + auto cid = server->openConnection(); + + // A private instance, as a create-style action would run on. + WaitReply anon; + server->handle(morph::wire::encode(morph::wire::makeRegister("CS_SquareModel")), std::ref(anon), cid); + REQUIRE(anon.await()); + REQUIRE(anon.env.kind == "ok"); + + WaitReply promoted; + server->handle(morph::wire::encode(morph::wire::makeAssign("CS_SquareModel", "100", anon.env.modelId)), + std::ref(promoted), cid); + REQUIRE(promoted.await()); + REQUIRE(promoted.env.kind == "ok"); + + // It is the *same* instance, now reachable by key — nothing was recreated. + WaitReply attached; + server->handle(morph::wire::encode(morph::wire::makeRegisterShared("CS_SquareModel", "100")), std::ref(attached), + cid); + REQUIRE(attached.await()); + REQUIRE(attached.env.modelId == anon.env.modelId); + +} + +TEST_CASE("morph::backend::RemoteServer: assign never displaces the incumbent holder of a key", + "[remote][connection-scope][shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto& env = csEnv(); + auto server = std::make_shared(pool, env.dispatcher, env.registry); + auto cid = server->openConnection(); + + WaitReply incumbent; + server->handle(morph::wire::encode(morph::wire::makeRegisterShared("CS_SquareModel", "200")), std::ref(incumbent), + cid); + REQUIRE(incumbent.await()); + REQUIRE(incumbent.env.kind == "ok"); + + // A second instance promoting onto the taken key is a silent no-op: the + // incumbent always wins, so no already-attached client is redirected. + WaitReply other; + server->handle(morph::wire::encode(morph::wire::makeRegister("CS_SquareModel")), std::ref(other), cid); + REQUIRE(other.await()); + WaitReply clash; + server->handle(morph::wire::encode(morph::wire::makeAssign("CS_SquareModel", "200", other.env.modelId)), + std::ref(clash), cid); + REQUIRE(clash.await()); + REQUIRE(clash.env.kind == "ok"); + + WaitReply again; + server->handle(morph::wire::encode(morph::wire::makeRegisterShared("CS_SquareModel", "200")), std::ref(again), cid); + REQUIRE(again.await()); + REQUIRE(again.env.modelId == incumbent.env.modelId); +} + +TEST_CASE("morph::backend::RemoteServer: the new kinds reject an empty typeId", + "[remote][connection-scope][shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto& env = csEnv(); + auto server = std::make_shared(pool, env.dispatcher, env.registry); + + for (const auto& request : {morph::wire::makeAttach("", "1"), morph::wire::makeAssign("", "1", 7), + morph::wire::makeInstances("")}) { + WaitReply reply; + server->handle(morph::wire::encode(request), std::ref(reply)); + REQUIRE(reply.await()); + REQUIRE(reply.env.kind == "err"); + } +} + +TEST_CASE("morph::backend::RemoteServer: a shared register on a closed scope is refused", + "[remote][connection-scope][shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto& env = csEnv(); + auto server = std::make_shared(pool, env.dispatcher, env.registry); + + auto cid = server->openConnection(); + server->closeConnection(cid); + + WaitReply reg; + server->handle(morph::wire::encode(morph::wire::makeRegisterShared("CS_SquareModel", "5")), std::ref(reg), cid); + REQUIRE(reg.await()); + REQUIRE(reg.env.kind == "err"); + REQUIRE(reg.env.message == "connection closed"); + REQUIRE(server->health().liveModels == 0U); +} From e78e9a3edda8e045971a0fd4ed7cf7339296b8d7 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 28 Jul 2026 14:09:55 +0200 Subject: [PATCH 19/42] feat(core): declare a model's key without touching the model class A keyed model had to carry `using PrimaryKey = ...` in its own class body and then repeat the key's type knowledge in a separate BRIDGE_KEY_FROM line. That is the same fact in two places, free to drift, and it put framework vocabulary inside a class whose whole selling point is being plain C++. BRIDGE_MODEL_KEY(Model, Action, &Action::field) now does both jobs from the one line the author is already writing registrations on: it deduces the key type from the member pointer (specialising ModelKeyTraits) and records Action as the one that carries it. The model class is untouched -- a keyed model is now indistinguishable from an unkeyed one by inspection. Split from BRIDGE_KEY_FROM deliberately, because a model usually has several actions naming the same entity but can only have one explicit ModelKeyTraits specialisation: BRIDGE_MODEL_KEY appears once per model, BRIDGE_KEY_FROM marks every further action that carries the key. Same split for the result-sourced pair. `using PrimaryKey` still works and still wins -- infer by default, declare to override -- for the case where the key type differs from the field's. The bank's AccountModel and CustomerModel drop their aliases accordingly. Also removes docs/superpowers/2026-07-06-reactive-forms-bridge.md, which should not have been in the codebase. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 8 +- docs/spec/core/shared_instances.md | 76 +++++++-- .../2026-07-06-reactive-forms-bridge.md | 155 ------------------ docs/todo.md | 5 +- examples/bank/README.md | 7 +- .../include/bank/models/account_model.hpp | 5 +- .../include/bank/models/customer_model.hpp | 5 +- .../include/bank/models/account_model.hpp | 5 +- .../include/bank/models/customer_model.hpp | 5 +- include/morph/core/model_key.hpp | 146 +++++++++++++++-- include/morph/core/remote.hpp | 55 ++++--- tests/net/test_socket_backend.cpp | 3 +- tests/test_shared_instances.cpp | 78 ++++++++- tests/test_subscription.cpp | 3 +- 14 files changed, 322 insertions(+), 234 deletions(-) delete mode 100644 docs/superpowers/2026-07-06-reactive-forms-bridge.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fafd62a..0bf14337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,9 +32,11 @@ API surface). (`scripts/check_deprecated_markers.sh`) enforcing that every `[[deprecated("...")]]` marker names a replacement and a target removal version. -- **Keyed, shareable model instances.** A model declares a nested `PrimaryKey` - alias; actions declare which field carries it (`BRIDGE_KEY_FROM`) or that - their result establishes it (`BRIDGE_KEY_FROM_RESULT`). +- **Keyed, shareable model instances.** One `BRIDGE_MODEL_KEY(Model, Action, + &Action::field)` beside the existing registrations designates the action that + defines a model's key and deduces the key type from that field, so the model's + own class body says nothing about keys; further actions carrying the same key + use `BRIDGE_KEY_FROM`, and creating actions use the `…_FROM_RESULT` variants. `BridgeHandler` joins a server-side directory keyed on `(typeId, primary)`, so handlers in one process — or in two clients over one `RemoteServer` — reach the same instance. Adds `attach()`, `primary()` and diff --git a/docs/spec/core/shared_instances.md b/docs/spec/core/shared_instances.md index 15ce382b..21a37bf3 100644 --- a/docs/spec/core/shared_instances.md +++ b/docs/spec/core/shared_instances.md @@ -52,7 +52,8 @@ Most of the mechanism is present and only needs connecting. - **Structural trait detection is an established pattern.** [views.md](../forms/views.md) detects `kind`, `query`, `title`, `rowKey` and friends "via a `requires`-expression, not inheritance or a marker base". - A model's key type is declared the same way. + `KeyedModel` is detected the same way, over either the deduced + `ModelKeyTraits` or an explicit nested alias. - **Per-instance authorization exists.** `IAuthorizer::authorizeInstance` is consulted on every `execute` and every `deregister`, carrying the instance id and its recorded owner ([session.md](../session/session.md)). @@ -61,32 +62,64 @@ Most of the mechanism is present and only needs connecting. ## Declaring a primary key -A model declares its key type as a nested alias. Declaring it is what makes the -model keyed; a model without it behaves exactly as an unkeyed model always has. +**A model's own class body says nothing about keys.** One line beside the +registrations the author is already writing designates the action that defines +the key, and the key's *type* is deduced from the member it names: ```cpp -class AccountModel { +class AccountModel { // a plain C++ class — unchanged public: - using PrimaryKey = std::int64_t; // detected structurally - // ... + dto::AccountInfo execute(const dto::GetAccount&); + dto::CommandResult execute(const dto::CloseAccount&); }; + +BRIDGE_REGISTER_MODEL (AccountModel, "AccountModel") +BRIDGE_REGISTER_ACTION(AccountModel, GetAccount, "GetAccount") +BRIDGE_REGISTER_ACTION(AccountModel, CloseAccount, "CloseAccount") + +BRIDGE_MODEL_KEY(AccountModel, GetAccount, &GetAccount::id); // key type deduced: std::int64_t +BRIDGE_KEY_FROM(CloseAccount, &CloseAccount::id); // also carries it ``` -`PrimaryKey` must be a type morph can carry on the wire and use as a map key: -an integral type or `std::string`. The key type is what -[`instances()`](#enumerating-live-instances) is typed on, so it is visible in -user code as `AccountModel::PrimaryKey`. +`BRIDGE_MODEL_KEY` appears **once per model** — it is an explicit +specialisation of `ModelKeyTraits` and cannot be repeated. Every *other* +action naming the same entity uses `BRIDGE_KEY_FROM`, which records only that +the action carries the key. Most models need just the one line, because most +have a single loader action and the rest are keyless. + +A key type must be one morph can carry on the wire and use as a map key: an +integral type or `std::string`. + +A model may still declare `using PrimaryKey = …` in its own body, and that wins +over the deduced type — *infer by default, declare to override*, the same rule +the rest of `morph::forms` follows. It is useful only when the key type differs +from the field's type. + +### Attachment is automatic + +A handler names a key exactly once, in a keyed action; everything after it +follows the handler: + +```cpp +BridgeHandler first{bridge, gui}, second{bridge, gui}; + +first .execute(GetAccount{.id = 32}); // no instance for 32 → constructs one +second.execute(GetAccount{.id = 32}); // 32 is live → attaches, constructs nothing + +first .execute(Deposit{.amountMinor = 100}); // keyless → instance 32 +second.execute(GetBalance{}); // keyless → instance 32, sees the 100 +``` ## Where the key comes from A keyed action declares which of its fields carries the key. Different actions spell it differently, which is why the declaration is per action rather than per -model: +model — one of them additionally defines the model's key type: ```cpp -BRIDGE_KEY_FROM(GetAccount, &GetAccount::id) -BRIDGE_KEY_FROM(Deposit, &Deposit::accountId) -BRIDGE_KEY_FROM(CloseAccount, &CloseAccount::id) +BRIDGE_MODEL_KEY(AccountModel, GetAccount, &GetAccount::id) // defines + carries +BRIDGE_KEY_FROM(Deposit, &Deposit::accountId) // carries +BRIDGE_KEY_FROM(CloseAccount, &CloseAccount::id) // carries ``` An action that *creates* the entity has no key to carry — it produces one. Such @@ -94,7 +127,7 @@ an action sources the key from its **result**, exactly as a database insert returns its generated primary key: ```cpp -BRIDGE_KEY_FROM_RESULT(OpenAccount, &dto::AccountInfo::id) +BRIDGE_MODEL_KEY_FROM_RESULT(CustomerModel, OpenAccount, &dto::AccountInfo::id) ``` Actions with neither declaration are **keyless**, and most actions are: they run @@ -292,11 +325,13 @@ strictly reduces pressure on it. | Symbol | Signature | Meaning | |---|---|---| -| `Model::PrimaryKey` | nested type alias | Declares the model keyed. Integral or `std::string`. Detected structurally. | +| `BRIDGE_MODEL_KEY(M, A, &A::f)` | macro | Designates `A` as the action defining `M`'s key, and deduces the key type from `f`. Once per model. | +| `Model::PrimaryKey` | optional nested alias | Overrides the deduced key type. Integral or `std::string`. | | `morph::bridge::AllowShared` | tag type | Second template argument of `BridgeHandler`. Opts the handler into the directory. | | `morph::bridge::NoSharing` | tag type | The default. Today's isolated-instance behaviour. | -| `BRIDGE_KEY_FROM(A, &A::field)` | macro | Declares that action `A` carries its model's key in `field`. | -| `BRIDGE_KEY_FROM_RESULT(A, &R::field)` | macro | Declares that `A`'s *result* establishes the key. | +| `BRIDGE_KEY_FROM(A, &A::field)` | macro | Declares that a further action `A` also carries the key. | +| `BRIDGE_MODEL_KEY_FROM_RESULT(M, A, &R::field)` | macro | As `BRIDGE_MODEL_KEY`, but the key comes from `A`'s *result*. | +| `BRIDGE_KEY_FROM_RESULT(A, &R::field)` | macro | A further creating action whose result establishes the key. | | `handler.attach(key)` | `void` | Attaches (or re-points) without executing an action. | | `handler.primary()` | `std::optional` | The handler's current primary; empty if unattached. | | `handler.instances()` | `Completion>` | Snapshot of live shared keys for this model type. | @@ -307,6 +342,11 @@ strictly reduces pressure on it. compile-time property, so it is visible at the declaration and cannot vary per call. Which instance it shares is a runtime property, because that is what the user is choosing at runtime. +- **The key type is deduced, not restated.** Writing it in the model class as + well as in the action field would be the same fact in two places, free to + drift. `BRIDGE_MODEL_KEY` reads it off the member pointer it is already given, + which is why a keyed model's class body is indistinguishable from an unkeyed + one's. - **The directory is server-side.** A client-side cache would solve the bank's five-connections problem but not the one that matters — two clients diverging on the same entity. Server-side is the whole reason this needs wire changes. diff --git a/docs/superpowers/2026-07-06-reactive-forms-bridge.md b/docs/superpowers/2026-07-06-reactive-forms-bridge.md deleted file mode 100644 index 35477866..00000000 --- a/docs/superpowers/2026-07-06-reactive-forms-bridge.md +++ /dev/null @@ -1,155 +0,0 @@ -# Reactive Forms over Bridge/BridgeHandler - -The QML forms demo (`examples/forms/gui_qml`) talks to `lab::LabModel` -exclusively through `morph::bridge::Bridge`/`BridgeHandler` — the -framework's real client API. Forms are reactive: an action fires -automatically as soon as its required fields are valid, on every edit; there -is no submit button. Adding a new action to `lab_model.hpp` (or any -`BRIDGE_REGISTER_ACTION` call site) requires zero lines beyond the -registration itself — no per-action GUI code exists anywhere. - -## Architecture - -``` -DynamicForm.qml ──revalidate()──▶ FormsController::submitIfValid(actionType, bodyJson) - │ - ▼ - BridgeHandler::executeJson(actionType, bodyJson) - │ (string → closure lookup) - ▼ - ActionExecuteRegistry::execute(modelId, actionId, handler, body) - │ (type-erased closure, registered by - │ BRIDGE_REGISTER_ACTION at static init) - ▼ - BridgeHandler::execute(action) - │ (real compile-time path: sessions, - │ backend switches, completions) - ▼ - Bridge ─▶ LocalBackend ─▶ LabModel::execute -``` - -### `ActionExecuteRegistry` (`include/morph/bridge.hpp`) - -A process-wide singleton mapping `(modelId, actionId)` strings to -type-erased executors. It is what lets QML dispatch actions it only knows -by name: - -```cpp -class ActionExecuteRegistry { -public: - using Executor = std::function<::morph::async::Completion(void* /* BridgeHandler* */, - std::string_view bodyJson)>; - template - void registerAction(std::string_view modelId, std::string_view actionId); - - // Throws std::runtime_error if no executor is registered for the pair. - ::morph::async::Completion execute(std::string_view modelId, std::string_view actionId, - void* handler, std::string_view bodyJson) const; - - static ActionExecuteRegistry& instance(); -}; -``` - -Each registered closure deserialises the JSON body via -`ActionTraits::fromJson`, calls the real -`BridgeHandler::execute()` (so sessions, backend switches, -and completions behave exactly as at hand-written call sites), and -re-serialises the result via `ActionTraits::resultToJson`. It -resolves on the handler's GUI executor (`BridgeHandler::guiExecutor()`). - -Registration happens inside `BRIDGE_REGISTER_ACTION_4` -(`include/morph/registry.hpp`) via -`morph::model::detail::registerActionExecutorOnce`, next to -the existing server-side `registerActionOnce` call. Every action registered -with `BRIDGE_REGISTER_ACTION` therefore has a generic-execute entry -automatically. - -**Hard requirement:** `registerActionExecutorOnce` is only defined in -`bridge.hpp`, so every translation unit that calls `BRIDGE_REGISTER_ACTION` -must include `bridge.hpp` (directly or transitively), or its static -initializer fails to link. - -This registry is the client-side counterpart of -`morph::model::detail::ActionDispatcher` (`registry.hpp`), which calls -`Model::execute` directly against an owned model holder and is only used -server-side. Both use the same `void*`/`std::function` type-erasure idiom. - -### `BridgeHandler::executeJson` (`bridge.hpp`) - -```cpp -::morph::async::Completion executeJson(std::string_view actionType, std::string_view bodyJson); -``` - -Thin wrapper: looks up the executor under -`(ModelTraits::typeId(), actionType)` and invokes it with `this`. - -### `FormsController` (`examples/forms/gui_qml/FormsController.{hpp,cpp}`) - -Owns the client stack: - -- `morph::exec::ThreadPoolExecutor _pool` (worker) and - `morph::qt::QtExecutor _gui` (GUI-thread delivery). -- `morph::bridge::Bridge _bridge{std::make_unique(_pool)}`. -- `morph::bridge::BridgeHandler _handler{_bridge, &_gui}`. - -QML-facing API: - -- `Q_INVOKABLE void submitIfValid(QString actionType, QString bodyJson)` — - fully generic, no per-action branches. Calls - `_handler.executeJson(...)`; `.then()`/`.onError()` emit - `replyReceived(actionType, ok, payload)`. -- `Q_INVOKABLE void fetchOptions(QString optionsAction, QString bodyJson)` — - equally generic: calls `_handler.executeJson(optionsAction, bodyJson)` and - emits `optionsReceived(optionsAction, ok, payload)`. `bodyJson` is `"{}"` - for an independent `Choice` and `{parentField: value, ...}` for a - dependent one (`x-optionsDependsOn`); `DynamicForm.qml` decides which and - when to re-fetch, and `FormsController` stays a thin, name-agnostic - pass-through for any registered action — not a fixed, hand-known query. - -### `DynamicForm.qml` - -`revalidate()` computes `ready` and assembles the full JSON body -(`previewLine`) on every edit. When `ready` is `true` after an edit, it -calls `controller.submitIfValid(actionType, previewLine)` directly. -`previewLine` stays visible as a preview of what was last sent; -`resultText` updates live from the `onReplyReceived` handler. - -## Error handling - -- Unknown `actionType`: `executeJson` throws; `submitIfValid` catches and - emits `replyReceived` with `ok=false` (never lets an exception cross - Qt's slot-invocation boundary). -- Malformed JSON body: `ActionTraits::fromJson` throws - `ParseError`; same catch-and-report path. Defence-in-depth only — QML - gates on `revalidate()`'s required-field/regex checks before calling - `submitIfValid`. -- `Model::execute` throwing (e.g. `RecordMeasurement`'s `validate()` - guard): propagates through `Completion::onError` to - `replyReceived(actionType, false, message)`. - -## Known limitations - -- No coalescing: a burst of edits while a call is in flight queues - multiple concurrent `Bridge` calls; each resolves `replyReceived` - independently, last-writer-wins on `resultText`. If this proves janky, a - ~150 ms debounce timer in `DynamicForm.qml` is a self-contained - follow-up. -- A compile-time, per-field API is not usable generically: glaze - plain-aggregate reflection exposes only field names, not compile-time - member pointers. The generic path goes through `executeJson` instead. -- The options combo-box fetch is not reactive; it runs once per form from - `Component.onCompleted`. -- `examples/forms/main.cpp` (schema dump / `--emit-html` / REPL) has no GUI - and does not use `FormsController`; the static `--emit-html` page is pure - JS with its own test (`test_html_math.mjs`). - -## Testing - -- `tests/test_bridge_execute_json.cpp` (Catch2): registers a test action - via `BRIDGE_REGISTER_ACTION`, asserts `executeJson` resolves with the - JSON-encoded result, and that a malformed body surfaces a parse error - through `onError`. -- `examples/forms/gui_qml/tests/tst_main.cpp` (Qt Quick Test): drives - `DynamicForm` through simulated field edits and asserts `resultText` - updates without any button click. Runs offscreen; the test target - deploys the Qt runtime + offscreen plugin. diff --git a/docs/todo.md b/docs/todo.md index 9b01ff1e..7e0dab46 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -61,8 +61,9 @@ models". ### F2 — Keyed, shareable model instances · P0 · shipped -A model declares a `PrimaryKey`; actions declare which field carries it (or that -their *result* establishes it); `BridgeHandler` opts a handler +One `BRIDGE_MODEL_KEY(Model, Action, &Action::field)` line beside the existing +registrations declares a model's key — the type is deduced from the field, so +the model class itself is untouched; `BridgeHandler` opts a handler into a **server-side** instance directory, so instances are reusable across clients. `instances()` enumerates the live keys. A keyed action re-points a handler rather than re-keying an instance, so key collisions do not arise. diff --git a/examples/bank/README.md b/examples/bank/README.md index 34375aa4..c83716e1 100644 --- a/examples/bank/README.md +++ b/examples/bank/README.md @@ -47,9 +47,10 @@ GUI / CLI ──actions/results (plain DTOs)──▶ morph Bridge ──▶ Mod ### Stateful, keyed models -`AccountModel` holds **one account, in memory**, for the lifetime of the instance. It -declares `using PrimaryKey = std::int64_t`, so morph keys instances by account id, and -`GetAccount`/`CloseAccount` declare that they carry that key (`BRIDGE_KEY_FROM`). Two +`AccountModel` holds **one account, in memory**, for the lifetime of the instance. The +class itself says nothing about keys: a single `BRIDGE_KEY_FROM(AccountModel, GetAccount, +&GetAccount::id)` next to the other registrations both deduces the key type and records +which action carries it, so morph keys instances by account id. Two `BridgeHandler` handlers naming the same account — in one GUI, or in two clients over one `RemoteServer` — reach a single instance and a single balance. diff --git a/examples/bank/gui_wasm/include/bank/models/account_model.hpp b/examples/bank/gui_wasm/include/bank/models/account_model.hpp index df32e45e..b599c2de 100644 --- a/examples/bank/gui_wasm/include/bank/models/account_model.hpp +++ b/examples/bank/gui_wasm/include/bank/models/account_model.hpp @@ -19,9 +19,6 @@ namespace bank { /// @brief One customer account, cached in the instance (in-memory backend). class AccountModel { public: - /// @brief Account id. Declaring this alias is what makes the model keyed. - using PrimaryKey = std::int64_t; - dto::AccountInfo execute(const dto::GetAccount& action); dto::CommandResult execute(const dto::CloseAccount& action); @@ -44,5 +41,5 @@ BRIDGE_REGISTER_MODEL(AccountModel, "AccountModel") BRIDGE_REGISTER_ACTION(AccountModel, GetAccount, "GetAccount") BRIDGE_REGISTER_ACTION(AccountModel, CloseAccount, "CloseAccount") -BRIDGE_KEY_FROM(GetAccount, &GetAccount::id); +BRIDGE_MODEL_KEY(AccountModel, GetAccount, &GetAccount::id); BRIDGE_KEY_FROM(CloseAccount, &CloseAccount::id); diff --git a/examples/bank/gui_wasm/include/bank/models/customer_model.hpp b/examples/bank/gui_wasm/include/bank/models/customer_model.hpp index e969cf0e..9c3a404e 100644 --- a/examples/bank/gui_wasm/include/bank/models/customer_model.hpp +++ b/examples/bank/gui_wasm/include/bank/models/customer_model.hpp @@ -15,9 +15,6 @@ namespace bank { /// @brief One customer: lists and opens the accounts they own (in-memory). class CustomerModel { public: - /// @brief Owner username. Declaring this alias is what makes the model keyed. - using PrimaryKey = std::string; - dto::AccountInfo execute(const dto::OpenAccount& action); dto::AccountList execute(const dto::ListAccounts& action); }; @@ -32,5 +29,5 @@ BRIDGE_REGISTER_MODEL(CustomerModel, "CustomerModel") BRIDGE_REGISTER_ACTION(CustomerModel, OpenAccount, "OpenAccount") BRIDGE_REGISTER_ACTION(CustomerModel, ListAccounts, "ListAccounts") -BRIDGE_KEY_FROM(ListAccounts, &ListAccounts::owner); +BRIDGE_MODEL_KEY(CustomerModel, ListAccounts, &ListAccounts::owner); BRIDGE_KEY_FROM(OpenAccount, &OpenAccount::owner); diff --git a/examples/bank/include/bank/models/account_model.hpp b/examples/bank/include/bank/models/account_model.hpp index 8dfade4f..3e748628 100644 --- a/examples/bank/include/bank/models/account_model.hpp +++ b/examples/bank/include/bank/models/account_model.hpp @@ -32,9 +32,6 @@ namespace bank { /// @brief One customer account: its row, cached, with reads served from memory. class AccountModel : private db::WithMapper { public: - /// @brief Account id. Declaring this alias is what makes the model keyed. - using PrimaryKey = std::int64_t; - /// @brief Returns the account, hydrating from SQLite only when needed. dto::AccountInfo execute(const dto::GetAccount& action); @@ -68,5 +65,5 @@ BRIDGE_REGISTER_ACTION(AccountModel, CloseAccount, "CloseAccount") // Both actions name the account they act on, so a shared handler attaches (or // re-points) to that account's instance on the way through. -BRIDGE_KEY_FROM(GetAccount, &GetAccount::id); +BRIDGE_MODEL_KEY(AccountModel, GetAccount, &GetAccount::id); BRIDGE_KEY_FROM(CloseAccount, &CloseAccount::id); diff --git a/examples/bank/include/bank/models/customer_model.hpp b/examples/bank/include/bank/models/customer_model.hpp index 718bc983..27fe3fb0 100644 --- a/examples/bank/include/bank/models/customer_model.hpp +++ b/examples/bank/include/bank/models/customer_model.hpp @@ -24,9 +24,6 @@ namespace bank { /// @brief One customer: lists and opens the accounts they own. class CustomerModel : private db::WithMapper { public: - /// @brief Owner username. Declaring this alias is what makes the model keyed. - using PrimaryKey = std::string; - /// @brief Opens a new account for the requested (or session) owner. dto::AccountInfo execute(const dto::OpenAccount& action); @@ -47,5 +44,5 @@ BRIDGE_REGISTER_ACTION(CustomerModel, ListAccounts, "ListAccounts", ::morph::mod // Keyed by owner. An empty `owner` means "the session principal", which is not // a key the directory can share on, so such a call simply runs on whatever // instance the handler already holds. -BRIDGE_KEY_FROM(ListAccounts, &ListAccounts::owner); +BRIDGE_MODEL_KEY(CustomerModel, ListAccounts, &ListAccounts::owner); BRIDGE_KEY_FROM(OpenAccount, &OpenAccount::owner); diff --git a/include/morph/core/model_key.hpp b/include/morph/core/model_key.hpp index f51390e5..87514ad5 100644 --- a/include/morph/core/model_key.hpp +++ b/include/morph/core/model_key.hpp @@ -40,16 +40,62 @@ template concept ModelKey = (std::integral && !std::same_as, bool>) || std::same_as, std::string>; -/// @brief Satisfied by model types that declare a `PrimaryKey` alias. +/// @brief The key type of a model, when one has been declared *for* it. /// -/// Declaring the alias is what opts a model into keyed, shareable instances. +/// Specialised by `BRIDGE_KEY_FROM`, which deduces the type from the action +/// member it is given — so a model does not have to say anything about keys +/// inside its own class. The primary template is deliberately empty: a model +/// with neither this specialisation nor a nested alias is simply unkeyed. +/// @tparam Model Concrete model type. +template +struct ModelKeyTraits {}; + +namespace detail { + +/// @brief Satisfied by a model that names its own key with a nested alias. +template +concept SelfDeclaredKey = requires { typename M::PrimaryKey; } && ModelKey; + +/// @brief Satisfied by a model whose key was deduced from a keyed action. +template +concept DeducedKey = requires { typename ModelKeyTraits::PrimaryKey; } && + ModelKey::PrimaryKey>; + +} // namespace detail + +/// @brief Satisfied by model types that have a primary key, however it was named. +/// +/// Two ways in, and neither requires touching the model's own class body beyond +/// the first: a nested `PrimaryKey` alias, or a `BRIDGE_KEY_FROM` declaration +/// that deduces the type from the action field carrying it. Following +/// `morph::forms`' standing rule — *infer by default, declare to override* — a +/// nested alias wins when both are present, which is what lets a model whose +/// key type differs from the field's type (an `int` column keyed as a +/// `std::string`, say) state that explicitly. +template +concept KeyedModel = detail::SelfDeclaredKey || detail::DeducedKey; + +namespace detail { + +/// @brief Picks the nested alias when present, else the deduced one. template -concept KeyedModel = requires { typename M::PrimaryKey; } && ModelKey; +struct KeyTypeOf { + /// @brief The resolved key type. + using type = ModelKeyTraits::PrimaryKey; +}; + +template +struct KeyTypeOf { + /// @brief The resolved key type — the model's own alias takes precedence. + using type = M::PrimaryKey; +}; -/// @brief The declared key type of a keyed model. +} // namespace detail + +/// @brief The primary key type of a keyed model. /// @tparam M Keyed model type. template -using PrimaryKeyOf = M::PrimaryKey; +using PrimaryKeyOf = detail::KeyTypeOf::type; /// @brief Encodes a primary key as its canonical wire string. /// @@ -114,6 +160,23 @@ struct ActionKeyTraits { namespace detail { +/// @brief The type of the data member a pointer-to-member points at. +/// +/// Lets `BRIDGE_KEY_FROM` deduce the model's key type from the action field it +/// is handed, so the key type is never written out twice. +template +struct MemberType; + +template +struct MemberType { + /// @brief The member's own type. + using type = V; +}; + +/// @brief Convenience alias for `MemberType::type`. +template +using MemberTypeOf = MemberType>::type; + /// @brief Satisfied by actions whose key is carried in the action payload. template concept PayloadKeyed = ActionKeyTraits::hasKey && !ActionKeyTraits::fromResult; @@ -130,15 +193,53 @@ concept ResultKeyed = ActionKeyTraits::hasKey && ActionKeyTraits::fromResu // matching BRIDGE_REGISTER_MODEL/ACTION in registry.hpp: they must emit a template // specialisation at global scope, which no function template can do. -/// @brief Declares that action `A` carries its model's primary key in `MEMBER`. +/// @brief Declares that action `A` is the one that defines model `M`'s primary key. +/// +/// One line does both jobs, so the model's own class body needs to say nothing +/// about keys: the key *type* is deduced from `MEMBER`'s type (defining +/// `ModelKeyTraits`), and `A` is recorded as an action that carries it +/// (defining `ActionKeyTraits`). /// -/// `MEMBER` is a pointer-to-data-member of `A` (e.g. `&GetAccount::id`) whose -/// type satisfies `morph::model::ModelKey`. Executing such an action on a -/// shareable handler attaches (or re-points) that handler to the instance -/// holding the named key, creating it if no instance holds it yet. +/// Executing such an action on a shareable handler attaches — or re-points — +/// that handler to the instance holding the named key, constructing one only if +/// no instance holds it yet. Every *keyless* action on that handler afterwards +/// lands on the same instance, which is what makes the common case free of +/// ceremony: /// -/// Must appear at global scope, in exactly one translation unit, like the other -/// `BRIDGE_REGISTER_*` macros. +/// ```cpp +/// BRIDGE_KEY_FROM(AccountModel, LoadAccount, &LoadAccount::id); +/// +/// BridgeHandler first{bridge, gui}, second{bridge, gui}; +/// first .execute(LoadAccount{.id = 32}); // constructs the instance for 32 +/// second.execute(LoadAccount{.id = 32}); // attaches to it; constructs nothing +/// first .execute(Deposit{.amount = 100}); // keyless -> instance 32 +/// second.execute(GetBalance{}); // keyless -> instance 32, sees the 100 +/// ``` +/// +/// `MEMBER` is a pointer-to-data-member of `A` (e.g. `&LoadAccount::id`) whose +/// type satisfies `morph::model::ModelKey`. Must appear at global scope, in +/// exactly one translation unit, like the other `BRIDGE_REGISTER_*` macros. +#define BRIDGE_MODEL_KEY(M, A, MEMBER) \ + template <> \ + struct morph::model::ActionKeyTraits { \ + static constexpr bool hasKey = true; \ + static constexpr bool fromResult = false; \ + static std::string key(const A& action) { return morph::model::keyToString(action.*MEMBER); } \ + }; \ + template <> \ + struct morph::model::ModelKeyTraits { \ + using PrimaryKey = morph::model::detail::MemberTypeOf; \ + } + +/// @brief Declares that action `A` also carries its model's primary key in `MEMBER`. +/// +/// The companion to `BRIDGE_MODEL_KEY`, for the *other* actions that name the +/// same entity — `CloseAccount{.id = ...}` alongside `GetAccount{.id = ...}`. +/// It records only that `A` carries the key; the key's type has already been +/// established by the model's one `BRIDGE_MODEL_KEY` line, and an explicit +/// specialisation cannot be repeated. +/// +/// Must appear at global scope, in exactly one translation unit. #define BRIDGE_KEY_FROM(A, MEMBER) \ template <> \ struct morph::model::ActionKeyTraits { \ @@ -155,6 +256,27 @@ concept ResultKeyed = ActionKeyTraits::hasKey && ActionKeyTraits::fromResu /// result type (e.g. `&AccountInfo::id`). /// /// Must appear at global scope, in exactly one translation unit. +#define BRIDGE_MODEL_KEY_FROM_RESULT(M, A, MEMBER) \ + template <> \ + struct morph::model::ActionKeyTraits { \ + static constexpr bool hasKey = true; \ + static constexpr bool fromResult = true; \ + template \ + static std::string keyOfResult(const R& result) { \ + return morph::model::keyToString(result.*MEMBER); \ + } \ + }; \ + template <> \ + struct morph::model::ModelKeyTraits { \ + using PrimaryKey = morph::model::detail::MemberTypeOf; \ + } + +/// @brief Declares that action `A`'s result also establishes its model's key. +/// +/// The companion to `BRIDGE_MODEL_KEY_FROM_RESULT`, for a second creating +/// action on a model whose key type is already established. +/// +/// Must appear at global scope, in exactly one translation unit. #define BRIDGE_KEY_FROM_RESULT(A, MEMBER) \ template <> \ struct morph::model::ActionKeyTraits { \ diff --git a/include/morph/core/remote.hpp b/include/morph/core/remote.hpp index 2a37aa20..5d3326b6 100644 --- a/include/morph/core/remote.hpp +++ b/include/morph/core/remote.hpp @@ -587,6 +587,9 @@ class RemoteServer : public std::enable_shared_from_this { } private: + /// @brief Directory key: the `(model type id, primary)` pair an instance is filed under. + using DirectoryKey = std::pair; + /// @brief Releases one reference to @p mid, destroying it at zero. Caller holds `_regMtx`. /// /// A private instance has no `_attachCount` entry and is erased outright — @@ -669,6 +672,37 @@ class RemoteServer : public std::enable_shared_from_this { } } + /// @brief Attaches to an already-live directory entry, if there is one. Caller holds `_regMtx`. + /// + /// Factored out because `acquireSharedInstance` checks the directory twice — + /// once on entry, and again under the insert lock after building a holder + /// outside it, in case a concurrent request for the same key won the race. + /// The second check is by nature almost never taken, so duplicating the body + /// would leave a block that is both untested and free to drift from the one + /// that is. + /// + /// @param dirKey Directory key being acquired. + /// @param env Decoded request, for `callId`. + /// @param reply Reply sink; invoked only when this returns `true`. + /// @param cid Connection scope, or `0` for unscoped. + /// @return `true` if an entry existed and @p reply was invoked; `false` to keep going. + bool attachExistingLocked(const DirectoryKey& dirKey, const ::morph::wire::Envelope& env, + const std::function& reply, ConnectionId cid) { + auto found = _directory.find(dirKey); + if (found == _directory.end()) { + return false; + } + auto const mid = found->second; + _attachCount[mid] += 1; + if (!noteScopeAttachLocked(mid, cid)) { + releaseInstanceLocked(mid); + reply(::morph::wire::encode(::morph::wire::makeErr("connection closed", env.callId))); + return true; + } + reply(::morph::wire::encode(::morph::wire::makeOk(env.callId, {}, mid.v))); + return true; + } + /// @brief Acquires (or creates) the shared instance for `(typeId, primary)` and replies. /// /// The register-or-attach core shared by the `register` branch (when @@ -696,15 +730,7 @@ class RemoteServer : public std::enable_shared_from_this { if (releaseCurrent.v != 0U) { releaseScopedLocked(releaseCurrent, cid); } - if (auto found = _directory.find(dirKey); found != _directory.end()) { - auto const mid = found->second; - _attachCount[mid] += 1; - if (!noteScopeAttachLocked(mid, cid)) { - releaseInstanceLocked(mid); - reply(::morph::wire::encode(::morph::wire::makeErr("connection closed", env.callId))); - return; - } - reply(::morph::wire::encode(::morph::wire::makeOk(env.callId, {}, mid.v))); + if (attachExistingLocked(dirKey, env, reply, cid)) { return; } } @@ -716,15 +742,7 @@ class RemoteServer : public std::enable_shared_from_this { ::morph::exec::detail::ModelId const fresh{nextOpaqueId()}; { std::scoped_lock const lock{_regMtx}; - if (auto found = _directory.find(dirKey); found != _directory.end()) { - auto const mid = found->second; - _attachCount[mid] += 1; - if (!noteScopeAttachLocked(mid, cid)) { - releaseInstanceLocked(mid); - reply(::morph::wire::encode(::morph::wire::makeErr("connection closed", env.callId))); - return; - } - reply(::morph::wire::encode(::morph::wire::makeOk(env.callId, {}, mid.v))); + if (attachExistingLocked(dirKey, env, reply, cid)) { return; } if (limits.maxLiveModels != 0 && _models.size() >= limits.maxLiveModels) { @@ -1290,7 +1308,6 @@ class RemoteServer : public std::enable_shared_from_this { // _models/_owners so directory membership can never desync from instance // existence. Only instances registered with `shared` set and a non-empty // primary appear; a private instance has no entry in any of the three. - using DirectoryKey = std::pair; std::unordered_map _directory; std::unordered_map<::morph::exec::detail::ModelId, DirectoryKey, ::morph::exec::detail::ModelIdHash> _sharedKeyOf; std::unordered_map<::morph::exec::detail::ModelId, std::size_t, ::morph::exec::detail::ModelIdHash> _attachCount; diff --git a/tests/net/test_socket_backend.cpp b/tests/net/test_socket_backend.cpp index 9829182c..1725fc4a 100644 --- a/tests/net/test_socket_backend.cpp +++ b/tests/net/test_socket_backend.cpp @@ -50,7 +50,6 @@ struct SbTotal { }; struct SbCounterModel { - using PrimaryKey = std::int64_t; int value = 0; SbTotal execute(const SbBump& act) { value += act.by; @@ -60,7 +59,7 @@ struct SbCounterModel { BRIDGE_REGISTER_MODEL(SbCounterModel, "SbCounterModel") BRIDGE_REGISTER_ACTION(SbCounterModel, SbBump, "SbBump") -BRIDGE_KEY_FROM(SbBump, &SbBump::id); +BRIDGE_MODEL_KEY(SbCounterModel, SbBump, &SbBump::id); // NOLINTEND(misc-use-internal-linkage) struct SbSlowAction { diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp index 35259d4d..a755d957 100644 --- a/tests/test_shared_instances.cpp +++ b/tests/test_shared_instances.cpp @@ -58,6 +58,17 @@ struct ShiPeek { int unused = 0; }; +/// Actions for the auto-attach worked example below. +struct AutoLoad { + std::int64_t id = 0; +}; +struct AutoAdd { + std::int64_t amount = 0; +}; +struct AutoPeek { + int unused = 0; +}; + /// Creates the entity, so its key cannot be in the request — it comes back in /// the reply, exactly as a database insert returns its generated primary key. struct ShiCreate { @@ -77,7 +88,6 @@ struct ShiCreateAs { }; struct ShiCounterModel { - using PrimaryKey = std::int64_t; std::int64_t value = 0; @@ -107,12 +117,41 @@ BRIDGE_REGISTER_ACTION(ShiCounterModel, ShiPeek, "SHI_Peek") BRIDGE_REGISTER_ACTION(ShiCounterModel, ShiCreate, "SHI_Create") BRIDGE_REGISTER_ACTION(ShiCounterModel, ShiCreateAs, "SHI_CreateAs") -BRIDGE_KEY_FROM(ShiAddTo, &ShiAddTo::id); +BRIDGE_MODEL_KEY(ShiCounterModel, ShiAddTo, &ShiAddTo::id); BRIDGE_KEY_FROM(ShiRead, &ShiRead::id); BRIDGE_KEY_FROM_RESULT(ShiCreate, &ShiCreated::id); BRIDGE_KEY_FROM_RESULT(ShiCreateAs, &ShiCreated::id); // NOLINTEND(misc-use-internal-linkage) +// NOLINTBEGIN(misc-use-internal-linkage) +/// The whole point of BRIDGE_MODEL_KEY: this class is a plain C++ class. No +/// nested alias, no base, no macro inside the body — the key is declared once, +/// below, next to the registrations the author is already writing. +struct AutoLoadModel { + std::int64_t loaded = 0; + std::int64_t total = 0; + + ShiCounterState execute(const AutoLoad& act) { + loaded = act.id; + return {.value = total}; + } + ShiCounterState execute(const AutoAdd& act) { // keyless + total += act.amount; + return {.value = total}; + } + [[nodiscard]] ShiCounterState execute(const AutoPeek& /*act*/) const { return {.value = total}; } +}; + +BRIDGE_REGISTER_MODEL(AutoLoadModel, "SHI_AutoLoadModel") +BRIDGE_REGISTER_ACTION(AutoLoadModel, AutoLoad, "SHI_AutoLoad") +BRIDGE_REGISTER_ACTION(AutoLoadModel, AutoAdd, "SHI_AutoAdd") +BRIDGE_REGISTER_ACTION(AutoLoadModel, AutoPeek, "SHI_AutoPeek") + +// One line. It deduces PrimaryKey = std::int64_t from the member type *and* +// records AutoLoad as the action that carries it. +BRIDGE_MODEL_KEY(AutoLoadModel, AutoLoad, &AutoLoad::id); +// NOLINTEND(misc-use-internal-linkage) + namespace { using morph::bridge::AllowShared; @@ -404,6 +443,41 @@ TEST_CASE("instances() is empty before anything is attached", "[shared-instances REQUIRE(settle(handler.instances()).empty()); } +TEST_CASE("a keyed action attaches automatically; keyless ones follow the handler", + "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + + BridgeHandler first{bridge, &exec}; + BridgeHandler second{bridge, &exec}; + + // The keyed action is the only place a key is ever named. + settle(first.execute(AutoLoad{.id = 32})); + settle(second.execute(AutoLoad{.id = 32})); + + // One instance, not two: the second handler found the live one for key 32 + // and constructed nothing. + REQUIRE(settle(first.instances()) == std::vector{32}); + REQUIRE(first.primary().value_or(-1) == 32); + REQUIRE(second.primary().value_or(-1) == 32); + + // From here on neither handler mentions a key: the keyless actions simply + // land on the instance their handler is attached to — the same one. + REQUIRE(settle(first.execute(AutoAdd{.amount = 1})).value == 1); + REQUIRE(settle(second.execute(AutoAdd{.amount = 2})).value == 3); + REQUIRE(settle(first.execute(AutoPeek{})).value == 3); + REQUIRE(settle(second.execute(AutoPeek{})).value == 3); +} + +TEST_CASE("a model needs no key declaration in its own class body", "[shared-instances]") { + // AutoLoadModel declares no nested PrimaryKey; BRIDGE_MODEL_KEY deduced it + // from the action member it was handed. + STATIC_REQUIRE(morph::model::KeyedModel); + STATIC_REQUIRE(std::same_as, std::int64_t>); + STATIC_REQUIRE(morph::model::detail::PayloadKeyed); + STATIC_REQUIRE_FALSE(morph::model::ActionKeyTraits::hasKey); +} + TEST_CASE("primary keys round-trip through their canonical encoding", "[shared-instances]") { REQUIRE(morph::model::keyToString(-17) == "-17"); REQUIRE(morph::model::keyFromString("-17") == -17); diff --git a/tests/test_subscription.cpp b/tests/test_subscription.cpp index 12455fce..47d33af7 100644 --- a/tests/test_subscription.cpp +++ b/tests/test_subscription.cpp @@ -61,7 +61,6 @@ struct SubExplode { }; struct SubCounterModel { - using PrimaryKey = std::int64_t; std::int64_t value = 0; @@ -80,7 +79,7 @@ BRIDGE_REGISTER_ACTION(SubCounterModel, SubRead, "SUB_Read") BRIDGE_REGISTER_ACTION(SubCounterModel, SubLabel, "SUB_Label") BRIDGE_REGISTER_ACTION(SubCounterModel, SubExplode, "SUB_Explode") -BRIDGE_KEY_FROM(SubBump, &SubBump::id); +BRIDGE_MODEL_KEY(SubCounterModel, SubBump, &SubBump::id); BRIDGE_KEY_FROM(SubRead, &SubRead::id); BRIDGE_KEY_FROM(SubLabel, &SubLabel::id); BRIDGE_KEY_FROM(SubExplode, &SubExplode::id); From e09457505d283dafec2b40a025c145d6b8f3de90 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 28 Jul 2026 14:27:09 +0200 Subject: [PATCH 20/42] fix(remote): do not let maxLiveModels refuse a pure attach Writing the coverage test for the shared path found a real bug. The register branch's cheap early cap check ran before the shared fast path, so with maxLiveModels set, a shared register that would merely take another reference to a live instance was refused with "too many models". That is exactly backwards: the cap bounds live models, not attachments to them, and refusing here means a loaded server turns away the second client of an instance it is already hosting -- precisely when sharing is worth the most. The authoritative re-test inside acquireSharedInstance already distinguishes the two, because it runs where the insert does. Also covers the paths this exposed: IBackend's sharing defaults degrading to private instances, assignPrimary re-filing and its no-op inputs, a shared handler surviving switchBackend (local and local->remote), attach/instances under a denying authorizer, a result key on an already-attached handler, a subscriber with no executor, and instances() surfacing a key the client cannot decode rather than silently yielding 0. Patch coverage over instrumented lines: 92.45% -> 94.68%. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 56 ++++++++- docs/ARCHITECTURE.md | 35 ++++++ include/morph/core/remote.hpp | 10 +- tests/test_shared_instances.cpp | 211 ++++++++++++++++++++++++++++++++ 4 files changed, 310 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4cd37162..e1e050b2 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,60 @@ out of scope it cleanly deregisters itself. > locally via a capturing factory will fail at `register` time in remote mode if > it isn't macro-registered. +## Shared model instances + +By default each `BridgeHandler` owns its own model instance — two handlers for +`AccountModel` are two independent objects. When several screens should be +looking at *the same* account, give the model a **primary key** and opt the +handlers into sharing. The model class itself does not change: + +```cpp +struct AccountModel { // still a plain C++ class + AccountInfo execute(const LoadAccount&); // ← names the account + Balance execute(const Deposit&); // ← keyless + Balance execute(const GetBalance&); // ← keyless +}; + +BRIDGE_REGISTER_MODEL (AccountModel, "AccountModel") +BRIDGE_REGISTER_ACTION(AccountModel, LoadAccount, "LoadAccount") +BRIDGE_REGISTER_ACTION(AccountModel, Deposit, "Deposit") +BRIDGE_REGISTER_ACTION(AccountModel, GetBalance, "GetBalance") + +// One line: deduces the key type from the field, and marks LoadAccount as the +// action that carries it. +BRIDGE_MODEL_KEY(AccountModel, LoadAccount, &LoadAccount::id); +``` + +Attachment is then automatic — a key is named exactly once, and everything +afterwards follows the handler: + +```cpp +using morph::bridge::AllowShared; +morph::bridge::BridgeHandler screen {bridge, &guiExecutor}; +morph::bridge::BridgeHandler sidebar{bridge, &guiExecutor}; + +screen .execute(LoadAccount{.id = 32}); // no instance for 32 yet → constructs one +sidebar.execute(LoadAccount{.id = 32}); // 32 is live → attaches, constructs nothing + +screen .execute(Deposit{.amountMinor = 100}); // keyless → instance 32 +sidebar.execute(GetBalance{}); // keyless → instance 32, sees the 100 +``` + +- **The directory is server-side**, so with a remote backend two *clients* also + meet on one instance, not just two handlers in one process. +- **Lifetime is refcounted**: the instance lives until the last handler attached + to it goes away — including across a dropped connection. +- **`BridgeHandler` is unchanged.** Sharing is opt-in per handler; a + plain handler still gets its own private instance and is invisible to the + directory. +- `handler.instances()` returns the live keys, and `handler.attach(key)` binds + without executing anything. + +Sharing only earns its keep when a model actually *holds* state — see +[`examples/bank`](examples/bank), whose `AccountModel` keeps one account in +memory. Full design in +[`docs/spec/core/shared_instances.md`](docs/spec/core/shared_instances.md). + ## Subsystems morph is layered: the async/bridge core is always present; everything else is an @@ -205,7 +259,7 @@ opt-in header you include only if you need it. |---|---|---| | `morph::exec` | `executor.hpp`, `strand.hpp` | `IExecutor`, `ThreadPoolExecutor`, `MainThreadExecutor`, per-model `StrandExecutor` | | `morph::async` | `completion.hpp` | `Completion` — move-only result handle with `.then` / `.onError` | -| `morph::model` | `registry.hpp`, `model.hpp` | Registration traits, validators, `ActionDispatcher`, type-erased holders | +| `morph::model` | `registry.hpp`, `model.hpp`, `model_key.hpp` | Registration traits, validators, `ActionDispatcher`, type-erased holders, model primary keys | | `morph::backend` | `backend.hpp`, `remote.hpp` | `LocalBackend`, `RemoteServer`, `SimulatedRemoteBackend` | | `morph::bridge` | `bridge.hpp` | `Bridge`, `BridgeHandler` — the user-facing API | | `morph::wire` | `wire.hpp` | JSON `Envelope` protocol between client and server | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 24b52b8a..b81ea7d4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -412,6 +412,41 @@ handler.execute(MyAction{21}) .onError([](std::exception_ptr e) { /* runs on GUI thread */ }); ``` +## Keyed, shareable model instances + +A `BridgeHandler` normally registers its own model instance. A model that +declares a **primary key** can instead have its instances shared: handlers that +name the same key reach one instance, through a directory the *server* owns, so +the sharing spans clients and not merely handlers. + +The key is declared beside the registrations, never inside the model class: + +```cpp +BRIDGE_MODEL_KEY(AccountModel, LoadAccount, &LoadAccount::id); // key type deduced +BRIDGE_KEY_FROM(CloseAccount, &CloseAccount::id); // also carries it +``` + +`BRIDGE_MODEL_KEY` appears once per model — it specialises +`ModelKeyTraits`, which cannot be repeated — and every other action +naming the same entity uses `BRIDGE_KEY_FROM`. Actions with neither declaration +are *keyless*, which is the common case: they run against whichever instance +their handler is already attached to. + +### Behavior + +| Aspect | Default | +|---|---| +| **Opt-in** | `BridgeHandler`. Plain `BridgeHandler` keeps a private instance and never enters the directory. | +| **Attachment** | Automatic: executing a keyed action attaches, or re-points, the handler to that key's instance, constructing one only if none is live. | +| **Re-pointing** | A keyed action naming a different key moves the *handler*. Instances never change identity, so a key always maps to one instance. | +| **Lifetime** | Refcounted across every attachment, including across connections. The instance dies when the last one goes. | +| **Ownership** | A shared instance is recorded with no owner principal — per-instance ownership and cross-client sharing are mutually exclusive. | +| **Creating actions** | `BRIDGE_MODEL_KEY_FROM_RESULT` takes the key from the reply and promotes the instance the action ran on, so nothing it built is stranded. | + +Full design, including the wire additions (`primary`/`shared` fields and the +`attach`/`assign`/`instances` kinds) and the connection-scope refcount, is in +[`spec/core/shared_instances.md`](spec/core/shared_instances.md). + ## Instance subscriptions The framework offers two complementary surfaces for talking to a model: diff --git a/include/morph/core/remote.hpp b/include/morph/core/remote.hpp index 5d3326b6..35c36201 100644 --- a/include/morph/core/remote.hpp +++ b/include/morph/core/remote.hpp @@ -840,7 +840,15 @@ class RemoteServer : public std::enable_shared_from_this { // pay for authorize()/authenticate() and a model construction it // is about to discard. Advisory only — the binding check is the // re-test under the insert lock further below. - if (limits.maxLiveModels != 0) { + // + // Skipped for a *shared* register, which may well create nothing: + // if the key is already live it only takes another reference, and + // `maxLiveModels` caps live models, not attachments to them. + // Rejecting here would make a loaded server refuse the second + // client of an instance it is already hosting — exactly when + // sharing is worth the most. `acquireSharedInstance` re-tests the + // cap under the insert lock, where it can tell the two apart. + if (limits.maxLiveModels != 0 && (!env.shared || env.primary.empty())) { std::scoped_lock const lock{_regMtx}; if (_models.size() >= limits.maxLiveModels) { reply(::morph::wire::encode(::morph::wire::makeErr("too many models", env.callId))); diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp index a755d957..d2f6efc4 100644 --- a/tests/test_shared_instances.cpp +++ b/tests/test_shared_instances.cpp @@ -496,3 +496,214 @@ TEST_CASE("keyed models and keyed actions are detected structurally", "[shared-i STATIC_REQUIRE_FALSE(morph::model::detail::PayloadKeyed); STATIC_REQUIRE_FALSE(morph::model::ActionKeyTraits::hasKey); } + +// ── Paths the happy path never reaches ────────────────────────────────────── + +namespace { + +/// Refuses everything, to drive the server's unauthorized replies. +struct DenyAllAuthorizer : morph::session::IAuthorizer { + [[nodiscard]] bool authorize(const morph::session::Context& /*ctx*/, std::string_view /*modelType*/, + std::string_view /*actionType*/) const override { + return false; + } + [[nodiscard]] bool authorizeRegister(const morph::session::Context& /*ctx*/, + std::string_view /*modelType*/) const override { + return false; + } +}; + +/// An IBackend that overrides nothing beyond the two pure virtuals, so the +/// sharing defaults on the interface itself are exercised: they must degrade to +/// private, unshared behaviour rather than silently hand two callers one model. +struct MinimalBackend : morph::backend::detail::IBackend { + morph::exec::detail::ModelId registerModel( + const std::string& /*typeId*/, + std::function()> factory) override { + auto holder = factory(); + return morph::exec::detail::ModelId{++nextId}; + } + void deregisterModel(morph::exec::detail::ModelId /*mid*/) override {} + morph::async::Completion> execute(morph::exec::detail::ModelId /*mid*/, + morph::backend::detail::ActionCall /*call*/, + morph::exec::IExecutor* cbExec) override { + auto state = std::make_shared>>(); + morph::async::Completion> comp{state, cbExec}; + state->setException(std::make_exception_ptr(std::runtime_error("not implemented"))); + return comp; + } + void notifyBackendChanged() override {} + void cancelPending(const std::exception_ptr& /*exc*/) override {} + + std::uint64_t nextId = 0; +}; + +} // namespace + +TEST_CASE("IBackend's sharing defaults degrade to private instances", "[shared-instances]") { + MinimalBackend backend; + // A backend that has not implemented sharing must not pretend it has: the + // default registerModelShared ignores the primary and makes a private + // instance, and the directory it does not keep is empty. + auto first = backend.registerModelShared("SHI_CounterModel", + [] { return morph::model::detail::ModelFactory::create(); }, + {.contextKey = {}, .primary = "1"}); + auto second = backend.registerModelShared("SHI_CounterModel", + [] { return morph::model::detail::ModelFactory::create(); }, + {.contextKey = {}, .primary = "1"}); + REQUIRE(first.v != second.v); + REQUIRE(backend.listInstances("SHI_CounterModel").empty()); + REQUIRE_NOTHROW(backend.assignPrimary(first, "SHI_CounterModel", "1")); +} + +TEST_CASE("assignPrimary re-files an instance and ignores unusable input", "[shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + morph::backend::LocalBackend backend{pool}; + + auto mid = backend.registerModelShared("SHI_CounterModel", + [] { return morph::model::detail::ModelFactory::create(); }, + {.contextKey = {}, .primary = "old"}); + REQUIRE(backend.listInstances("SHI_CounterModel") == std::vector{"old"}); + + // Re-filing drops the previous entry rather than leaving the instance + // reachable under two keys, which would break the one-key-one-instance rule. + backend.assignPrimary(mid, "SHI_CounterModel", "new"); + REQUIRE(backend.listInstances("SHI_CounterModel") == std::vector{"new"}); + + // Neither of these names something actionable, so both are no-ops. + backend.assignPrimary(mid, "SHI_CounterModel", ""); + backend.assignPrimary(morph::exec::detail::ModelId{99999}, "SHI_CounterModel", "ghost"); + REQUIRE(backend.listInstances("SHI_CounterModel") == std::vector{"new"}); +} + +TEST_CASE("a shared handler survives switchBackend", "[shared-instances]") { + morph::testing::InlineExecutor exec; + morph::exec::ThreadPoolExecutor poolA{2}; + morph::exec::ThreadPoolExecutor poolB{2}; + Bridge bridge{std::make_unique(poolA)}; + + BridgeHandler attached{bridge, &exec}; + BridgeHandler neverAttached{bridge, &exec}; + settle(attached.execute(ShiAddTo{.id = 77, .amount = 4})); + + // The attached binding is re-registered on the new backend through the + // directory; the one that never attached has no instance to re-create and + // must simply stay unbound rather than being handed a stray one. + bridge.switchBackend(std::make_unique(poolB)); + + REQUIRE(attached.primary().value_or(-1) == 77); + REQUIRE(settle(attached.instances()) == std::vector{77}); + REQUIRE_FALSE(neverAttached.primary().has_value()); +} + +TEST_CASE("a shared register is refused once the server is at its model cap", "[shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool); + server->setLimitPolicy({.maxLiveModels = 1}); + + morph::wire::Envelope const first = morph::wire::makeRegisterShared("SHI_CounterModel", "1"); + auto firstReply = morph::wire::decode(server->handleInline(morph::wire::encode(first))); + REQUIRE(firstReply.kind == "ok"); + + // A *different* key needs a new instance, and there is no room for one. + auto second = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeRegisterShared("SHI_CounterModel", "2")))); + REQUIRE(second.kind == "err"); + REQUIRE(second.message == "too many models"); + + // Attaching to the key that already exists still works — it creates nothing. + auto again = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeRegisterShared("SHI_CounterModel", "1")))); + REQUIRE(again.kind == "ok"); + REQUIRE(again.modelId == firstReply.modelId); +} + +TEST_CASE("attach and instances are refused by an authorizer that denies", "[shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool, std::make_shared()); + + auto attach = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeAttach("SHI_CounterModel", "1")))); + REQUIRE(attach.kind == "err"); + REQUIRE(attach.message == "unauthorized"); + + // Enumeration is a read channel over the directory, gated separately so a + // deployer can refuse listing without refusing use. + auto listed = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeInstances("SHI_CounterModel")))); + REQUIRE(listed.kind == "err"); + REQUIRE(listed.message == "unauthorized"); +} + +TEST_CASE("a result-sourced key on an already-attached handler promotes in place", "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + + BridgeHandler handler{bridge, &exec}; + handler.attach(600); // already bound… + settle(handler.execute(ShiAddTo{.id = 600, .amount = 3})); + + // …so the create does not need an anonymous instance conjured for it; it + // runs on the one already held, and that instance is re-filed under the + // generated key with its state intact. + auto created = settle(handler.execute(ShiCreateAs{.wantId = 601, .initial = 9})); + REQUIRE(created.id == 601); + REQUIRE(handler.primary().value_or(-1) == 601); + REQUIRE(settle(handler.execute(ShiPeek{})).value == 9); +} + +TEST_CASE("a subscriber with no executor is called inline", "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + + BridgeHandler driver{bridge, &exec}; + BridgeHandler watcher{bridge, nullptr}; // no callback executor + driver.attach(700); + watcher.attach(700); + + std::int64_t seen = -1; + watcher.subscribe([&](ShiCounterState state) { seen = state.value; }); + settle(driver.execute(ShiAddTo{.id = 700, .amount = 6})); + REQUIRE(seen == 6); +} + +TEST_CASE("instances() surfaces a key this client cannot decode", "[shared-instances]") { + morph::testing::InlineExecutor exec; + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool); + + // A key the server is happy to file but that no `std::int64_t` can hold. + // The wire carries keys as strings, so nothing upstream rejects it; the + // decode has to fail somewhere, and failing loudly at the client boundary + // beats handing the caller a silently-wrong 0. + auto reg = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeRegisterShared("SHI_CounterModel", "not-a-number")))); + REQUIRE(reg.kind == "ok"); + + Bridge bridge{std::make_unique(*server)}; + BridgeHandler handler{bridge, &exec}; + + auto failed = std::make_shared>(false); + handler.instances().onError([failed](const std::exception_ptr&) { failed->store(true); }); + REQUIRE(morph::testing::waitUntil([&] { return failed->load(); })); +} + +TEST_CASE("an attached shared handler re-registers through a remote backend on switch", + "[shared-instances]") { + morph::testing::InlineExecutor exec; + morph::exec::ThreadPoolExecutor localPool{2}; + morph::exec::ThreadPoolExecutor serverPool{2}; + auto server = std::make_shared(serverPool); + + Bridge bridge{std::make_unique(localPool)}; + BridgeHandler handler{bridge, &exec}; + settle(handler.execute(ShiAddTo{.id = 800, .amount = 2})); + + // Going local -> remote must carry the *key* across, not just re-register + // something anonymous: the handler is still attached to 800 afterwards, and + // the server's directory knows it under that key. + bridge.switchBackend(std::make_unique(*server)); + + REQUIRE(handler.primary().value_or(-1) == 800); + REQUIRE(settle(handler.instances()) == std::vector{800}); +} From fd02245a6ff0c7032dd1abb37057e8de5ffa69e4 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 28 Jul 2026 14:28:06 +0200 Subject: [PATCH 21/42] fix(docs): reference the shared-instances spec the way ARCHITECTURE.md does Doxygen builds ARCHITECTURE.md as the mainpage and cannot resolve a markdown link to docs/spec/, which is outside its input set -- WARN_AS_ERROR turned that into a docs-build failure. Every other spec reference in this file is inline code for exactly that reason; this one now matches. Co-Authored-By: Claude Opus 5 (1M context) --- docs/ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b81ea7d4..20f19ad9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -445,7 +445,7 @@ their handler is already attached to. Full design, including the wire additions (`primary`/`shared` fields and the `attach`/`assign`/`instances` kinds) and the connection-scope refcount, is in -[`spec/core/shared_instances.md`](spec/core/shared_instances.md). +`docs/spec/core/shared_instances.md`. ## Instance subscriptions From 9f00d7e8c4a63464f2d316ff9187e7804d88786b Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 28 Jul 2026 14:48:43 +0200 Subject: [PATCH 22/42] test: close the remaining reachable gaps in the shared-instance paths Patch coverage over instrumented lines: 94.68% -> 97.43%. - a change-aware model registered through the shared path is still recorded as change-aware, so it keeps being notified across a backend switch; - the server re-files an instance onto a new key over the wire, dropping the old directory entry rather than leaving it reachable under two keys; - attach/instances stamp an authenticating authorizer's verified principal; - a refusing server surfaces as an exception on each of the remote backend's three control calls, rather than a bogus id or an empty list a caller would read as success; - an empty primary on the remote backend degrades to a private instance. The change-aware test asserts directly rather than polling: notifyBackendChanged posts onto the instance's strand and the following execute posts onto the same strand, so it is ordered strictly after. A poll there would have hidden a real ordering bug behind a retry -- and an earlier draft of it did exactly that, calling settle() (which contains REQUIREs) inside the predicate. What remains uncovered is a double-checked directory re-test that is by nature race-only, plus QtWebSocketBackend, which the coverage job does not build. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_shared_instances.cpp | 122 ++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp index d2f6efc4..f15cb2c3 100644 --- a/tests/test_shared_instances.cpp +++ b/tests/test_shared_instances.cpp @@ -58,6 +58,10 @@ struct ShiPeek { int unused = 0; }; +struct ShiAwareRead { + std::int64_t id = 0; +}; + /// Actions for the auto-attach worked example below. struct AutoLoad { std::int64_t id = 0; @@ -123,6 +127,21 @@ BRIDGE_KEY_FROM_RESULT(ShiCreate, &ShiCreated::id); BRIDGE_KEY_FROM_RESULT(ShiCreateAs, &ShiCreated::id); // NOLINTEND(misc-use-internal-linkage) +// NOLINTBEGIN(misc-use-internal-linkage) +/// Declares onBackendChanged(), so registering it shared must also record it as +/// change-aware — the bookkeeping LocalBackend keeps to avoid a dynamic_cast +/// sweep on every backend switch. +struct ShiAwareModel { + std::int64_t notified = 0; + void onBackendChanged() { notified += 1; } + [[nodiscard]] ShiCounterState execute(const ShiAwareRead& /*act*/) const { return {.value = notified}; } +}; + +BRIDGE_REGISTER_MODEL(ShiAwareModel, "SHI_AwareModel") +BRIDGE_REGISTER_ACTION(ShiAwareModel, ShiAwareRead, "SHI_AwareRead") +BRIDGE_MODEL_KEY(ShiAwareModel, ShiAwareRead, &ShiAwareRead::id); +// NOLINTEND(misc-use-internal-linkage) + // NOLINTBEGIN(misc-use-internal-linkage) /// The whole point of BRIDGE_MODEL_KEY: this class is a plain C++ class. No /// nested alias, no base, no macro inside the body — the key is declared once, @@ -707,3 +726,106 @@ TEST_CASE("an attached shared handler re-registers through a remote backend on s REQUIRE(handler.primary().value_or(-1) == 800); REQUIRE(settle(handler.instances()) == std::vector{800}); } + +namespace { + +/// Verifies every caller as "verified-user", so the server's attach/instances +/// branches take their authenticate-and-overwrite path. +struct VerifyingAuthorizer : morph::session::IAuthorizer { + [[nodiscard]] bool authorize(const morph::session::Context& /*ctx*/, std::string_view /*modelType*/, + std::string_view /*actionType*/) const override { + return true; + } + [[nodiscard]] std::optional authenticate( + const morph::session::Context& /*ctx*/) const override { + return std::string{"verified-user"}; + } +}; + +} // namespace + +TEST_CASE("a change-aware model is tracked when registered shared", "[shared-instances]") { + morph::testing::InlineExecutor exec; + morph::exec::ThreadPoolExecutor poolA{2}; + morph::exec::ThreadPoolExecutor poolB{2}; + Bridge bridge{std::make_unique(poolA)}; + + BridgeHandler handler{bridge, &exec}; + settle(handler.execute(ShiAwareRead{.id = 900})); + + // Registering through the shared path must record change-awareness exactly + // as the private path does, or the model silently stops being notified. + bridge.switchBackend(std::make_unique(poolB)); + + // No polling needed, and none wanted: notifyBackendChanged posts + // onBackendChanged onto the instance's strand, and this execute posts onto + // the same strand, so it is ordered strictly after — a poll here would + // merely hide a real ordering bug behind a retry. + REQUIRE(settle(handler.execute(ShiAwareRead{.id = 900})).value == 1); +} + +TEST_CASE("the server re-files an instance onto a new key over the wire", "[shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool); + + auto reg = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeRegisterShared("SHI_CounterModel", "old")))); + REQUIRE(reg.kind == "ok"); + + // Assigning a *new* key to an already-filed instance drops the old entry — + // leaving it reachable under two keys would break one-key-one-instance. + server->handleInline(morph::wire::encode(morph::wire::makeAssign("SHI_CounterModel", "new", reg.modelId))); + + auto listed = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeInstances("SHI_CounterModel")))); + std::vector keys; + REQUIRE_FALSE(glz::read_json(keys, listed.body)); + REQUIRE(keys == std::vector{"new"}); +} + +TEST_CASE("attach and instances stamp the verified principal", "[shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool, std::make_shared()); + + // An authenticating authorizer's identity must overwrite the client's claim + // on these kinds too, not only on register/execute. + auto attached = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeAttach("SHI_CounterModel", "1")))); + REQUIRE(attached.kind == "ok"); + + auto listed = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeInstances("SHI_CounterModel")))); + REQUIRE(listed.kind == "ok"); +} + +TEST_CASE("a refusing server surfaces as an exception on the remote backend", "[shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool, std::make_shared()); + morph::backend::SimulatedRemoteBackend backend{*server}; + + // Each control call reports the server's refusal rather than returning a + // bogus id or an empty list that a caller would mistake for success. + REQUIRE_THROWS(backend.registerModelShared( + "SHI_CounterModel", [] { return morph::model::detail::ModelFactory::create(); }, + {.contextKey = {}, .primary = "1"})); + REQUIRE_THROWS(backend.attachModel( + "SHI_CounterModel", [] { return morph::model::detail::ModelFactory::create(); }, + {.contextKey = {}, .primary = "1"}, morph::exec::detail::ModelId{0})); + REQUIRE_THROWS(backend.listInstances("SHI_CounterModel")); +} + +TEST_CASE("an empty primary on the remote backend degrades to a private instance", "[shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool); + morph::backend::SimulatedRemoteBackend backend{*server}; + + auto held = backend.registerModel("SHI_CounterModel", + [] { return morph::model::detail::ModelFactory::create(); }); + // No key to share on, so this releases what it holds and registers privately + // rather than entering the directory. + auto rebound = backend.attachModel( + "SHI_CounterModel", [] { return morph::model::detail::ModelFactory::create(); }, + {.contextKey = {}, .primary = {}}, held); + REQUIRE(rebound.v != 0U); + REQUIRE(backend.listInstances("SHI_CounterModel").empty()); +} From 77ef200682280a4d2e6b72ecc254032075ec42a6 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 11:40:48 +0300 Subject: [PATCH 23/42] fix(remote): gate assign behind the same authorizer check as attach/register --- docs/spec/core/shared_instances.md | 5 +++++ include/morph/core/remote.hpp | 16 ++++++++++++++++ tests/test_shared_instances.cpp | 18 ++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/docs/spec/core/shared_instances.md b/docs/spec/core/shared_instances.md index 21a37bf3..bd6b9655 100644 --- a/docs/spec/core/shared_instances.md +++ b/docs/spec/core/shared_instances.md @@ -284,6 +284,11 @@ authorizer silently defeat the feature: `authorize` (per model type and action) or of the model itself. - **`authorizeRegister` still gates creation.** An authorizer that refuses `register` for a model type refuses it whether or not the request is shared. +- **`attach` and `assign` are gated by `authorizeRegister` too**, the same + hook `register` uses. Filing an instance into the directory — whether by + creating it (`register`, `attach`) or by promoting one already live + (`assign`) — is bounds-checked identically; there is no path that reaches + the directory without it. - **`instances` is gated by `authorize`** for the model type with an empty action id, so an authorizer can refuse enumeration without refusing use. It leaks the set of live keys to anyone permitted to call it, which is a diff --git a/include/morph/core/remote.hpp b/include/morph/core/remote.hpp index 35c36201..1cc811d4 100644 --- a/include/morph/core/remote.hpp +++ b/include/morph/core/remote.hpp @@ -970,6 +970,22 @@ class RemoteServer : public std::enable_shared_from_this { if (env.typeId.empty()) { throw std::runtime_error("assign requires a typeId"); } + // Mirrors "attach"'s gate: filing an instance into the shared + // directory -- whether by creating it (register) or by + // promoting one already live (assign) -- is bounds-checked + // identically. Unlike "attach"/"register", assign never + // constructs a model, but it still changes what a future + // attacher of `primary` reaches, so it must not be reachable + // by an unauthenticated or unauthorized caller either. + if (auto verified = _authorizer->authenticate(env.session)) { + env.session.principal = std::move(*verified); + } else { + env.session.principal.clear(); + } + if (!_authorizer->authorizeRegister(env.session, env.typeId)) { + reply(::morph::wire::encode(::morph::wire::makeErr("unauthorized", env.callId))); + return; + } { std::scoped_lock const lock{_regMtx}; applyAssignLocked(env); diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp index f15cb2c3..6c4a0b02 100644 --- a/tests/test_shared_instances.cpp +++ b/tests/test_shared_instances.cpp @@ -654,6 +654,24 @@ TEST_CASE("attach and instances are refused by an authorizer that denies", "[sha REQUIRE(listed.message == "unauthorized"); } +TEST_CASE("assign is refused by an authorizer that denies", "[shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool, std::make_shared()); + + // Register a plain (non-shared) instance first -- register itself is + // refused too, but we want an `assign` attempt against a *known* modelId + // to prove assign has its own gate, not just an incidental empty-primary + // no-op. + auto reg = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeRegister("SHI_CounterModel")))); + REQUIRE(reg.kind == "err"); // register is refused too, as expected + + auto assign = morph::wire::decode(server->handleInline( + morph::wire::encode(morph::wire::makeAssign("SHI_CounterModel", "1", 0)))); + REQUIRE(assign.kind == "err"); + REQUIRE(assign.message == "unauthorized"); +} + TEST_CASE("a result-sourced key on an already-attached handler promotes in place", "[shared-instances]") { morph::testing::InlineExecutor exec; Bridge bridge{makeLocal(exec)}; From b1485bd959edef9809d0e213616909a9ff92aac4 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 12:06:12 +0300 Subject: [PATCH 24/42] fix(bridge): check liveness before touching the bridge in executeVia's result handler --- include/morph/core/bridge.hpp | 19 ++++++- tests/test_bridge_lifetime.cpp | 93 +++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index 1b93c137..7cf44b15 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -709,12 +709,27 @@ class Bridge { // then hang forever) and QtExecutor lets it reach the event loop // and std::terminate. Mirrors the forwarding guard in remote.hpp's // SimulatedRemoteBackend::execute. See docs/spec/bridge.md. + // + // A backend completion can in principle resolve after the + // Bridge is gone (see liveness()'s doc comment): the backend + // may be co-owned and outlive this Bridge, or this callback + // may already be running when ~Bridge() runs concurrently on + // another thread. Check liveness FIRST, before touching + // anything that reaches into the bridge -- both `onResult` + // (which, for a result-keyed action, calls back into this + // bridge via a captured raw pointer to assign the binding's + // primary) and hasSubscribers() (which reads `this`) must + // never run once the bridge might be gone. The typed result + // is still delivered to the caller's own Completion either + // way -- only the two bridge-touching side effects are + // skipped. try { auto* const typedResult = static_cast(vAny.get()); + bool const bridgeAlive = !alive.expired(); // Runs before the value is moved out and before the caller's // own .then, so a result-sourced primary key is adopted by // the binding before any user code observes the result. - if (onResult) { + if (onResult && bridgeAlive) { onResult(*typedResult); } // Fan the result out to everything attached to this @@ -722,7 +737,7 @@ class Bridge { // bridge's liveness token: a completion can in principle // resolve after the Bridge is gone. if constexpr (std::is_copy_constructible_v) { - if (hasSubscribers() && !alive.expired()) { + if (bridgeAlive && hasSubscribers()) { publishResult(::morph::exec::detail::ModelId{raw}, std::type_index{typeid(R)}, std::any{*typedResult}); } diff --git a/tests/test_bridge_lifetime.cpp b/tests/test_bridge_lifetime.cpp index 251a170e..26926c5d 100644 --- a/tests/test_bridge_lifetime.cpp +++ b/tests/test_bridge_lifetime.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// Regression tests for two Bridge memory-safety / robustness fixes: +// Regression tests for three Bridge memory-safety / robustness fixes: // FIX 2 — ~Bridge clears the active backend's reconnect handler and the // handler guards on the bridge's liveness token, so a reconnect fired // by a co-owned backend after the Bridge is destroyed is a safe no-op @@ -9,6 +9,11 @@ // thrown while moving the result into the typed completion and routes // it to onError, instead of letting it escape the callback executor // (which would hang the completion or terminate the Qt loop). +// FIX 5 — executeVia's `.then` closure checks the bridge's liveness token +// BEFORE touching anything that reaches into the bridge (`onResult` +// and `hasSubscribers()`), so a backend completion that resolves +// after `~Bridge()` has run does not dereference the dangling +// `Bridge`. #include #include @@ -133,8 +138,57 @@ class PrebuiltResultBackend : public morph::backend::detail::IBackend { void cancelPending(const std::exception_ptr&) override {} }; +// ── FIX 5 fixtures ─────────────────────────────────────────────────────────── + +// A plain, copy-constructible result -- unlike ThrowOnMove above, this must +// move/copy cleanly so the test isolates FIX 5's ordering bug (hasSubscribers() +// reading a dangling `this`) rather than FIX 4's move-exception path. +struct DeferredAction {}; +struct DeferredModel { + int execute(const DeferredAction&) const { return 0; } +}; + +// A backend whose execute() never resolves on its own: it hands back a fresh +// CompletionState and stashes it, so the test can destroy the Bridge while the +// completion is still pending and only resolve it afterward -- reproducing the +// exact race executeVia's liveness guard exists for. +class DeferredResultBackend : public morph::backend::detail::IBackend { +public: + morph::exec::detail::ModelId registerModel( + const std::string&, std::function()>) override { + return morph::exec::detail::ModelId{1}; + } + void deregisterModel(morph::exec::detail::ModelId) override {} + morph::async::Completion> execute(morph::exec::detail::ModelId, + morph::backend::detail::ActionCall, + morph::exec::IExecutor* cbExec) override { + state = std::make_shared>>(); + return {state, cbExec}; + } + void notifyBackendChanged() override {} + void cancelPending(const std::exception_ptr&) override {} + + // Left un-resolved by execute(); the test resolves it directly once it + // wants the completion to fire. + std::shared_ptr>> state; +}; + } // namespace +template <> +struct morph::model::ModelTraits { + static constexpr std::string_view typeId() { return "BL_DeferredModel"; } +}; +template <> +struct morph::model::ActionTraits { + using Result = int; + static constexpr std::string_view typeId() { return "BL_DeferredAction"; } + static std::string toJson(const DeferredAction&) { return "{}"; } + [[maybe_unused]] static DeferredAction fromJson(std::string_view) { return {}; } + static std::string resultToJson(const int&) { return "0"; } + static int resultFromJson(std::string_view) { return 0; } +}; + template <> struct morph::model::ModelTraits { static constexpr std::string_view typeId() { return "BL_ThrowModel"; } @@ -222,3 +276,40 @@ TEST_CASE("executeVia routes a throwing result move to onError instead of hangin REQUIRE_FALSE(okFired.load()); REQUIRE(errWhat.find("move threw") != std::string::npos); } + +// ── FIX 5 ──────────────────────────────────────────────────────────────────── + +TEST_CASE("Bridge: a completion resolving after the bridge is destroyed does not touch the dead bridge", + "[bridge][lifetime]") { + auto backendOwner = std::make_unique(); + auto* const backendPtr = backendOwner.get(); + morph::testing::InlineExecutor cbExec; + + std::shared_ptr>> pending; + { + morph::bridge::Bridge bridge{std::move(backendOwner)}; + + auto binding = std::make_shared(); + binding->typeId = "BL_DeferredModel"; + binding->modelFactory = [] { return morph::model::detail::ModelFactory::create(); }; + bridge.registerHandler(binding); + + // Kicks off executeVia's `.then` chain against `bridge`, but the fake + // backend never resolves it -- the completion (and the `this`-capturing + // closure attached to it) is still pending when `bridge` goes out of + // scope below. + auto completion = bridge.executeVia(binding, DeferredAction{}, &cbExec); + (void)completion; + + pending = backendPtr->state; + REQUIRE(pending); + // ~Bridge runs here, with the completion still unresolved. + } + // The Bridge above is already destroyed. Resolving the backend's completion + // now must not crash or touch freed memory -- this is exactly the + // "completion resolves after the Bridge is gone" race executeVia's liveness + // guard exists for. Catch2 reports a crash (e.g. under ASan) as a test + // failure/signal, so no further assertion is needed beyond reaching SUCCEED. + pending->setValue(std::static_pointer_cast(std::make_shared(42))); + SUCCEED("resolving a completion after Bridge destruction did not touch the dead bridge"); +} From 8c96ec001cfec8b86fc8a9993f17db7dba915c19 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 12:07:57 +0300 Subject: [PATCH 25/42] docs(bridge): document executeVia's liveness-gated result handling --- docs/spec/core/bridge.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/spec/core/bridge.md b/docs/spec/core/bridge.md index 3fcc749b..73fdaf56 100644 --- a/docs/spec/core/bridge.md +++ b/docs/spec/core/bridge.md @@ -122,6 +122,21 @@ fires instead. This mirrors the identical forwarding guard in path (`ActionExecuteRegistry::registerAction`) guards its own `resultToJson` forwarding the same way. +Before any of that forwarding, the same `.then` closure checks the bridge's +`_liveness` token (captured as `alive = liveness()`) and gates every +bridge-touching side effect on it being unexpired — checked first, before +`onResult` or `hasSubscribers()` run. A backend completion can in principle +resolve after the `Bridge` is gone: the backend may be co-owned and outlive +this `Bridge`, or the callback may already be running when `~Bridge()` runs +concurrently on another thread. `onResult` — used for a result-keyed action to +call back into the bridge via a captured raw pointer and adopt the binding's +primary — and `hasSubscribers()` — which reads `this` — must never run once +the bridge might be gone; running either on a dangling `Bridge` is a +use-after-free. The typed result is still delivered to the caller's own +`Completion` either way (`typedState->setValue` does not touch the bridge) — +only the two bridge-touching side effects are skipped when the token has +expired. + **`switchBackend(newBackend)`** replaces the active backend atomically: the switch either fully succeeds or leaves everything exactly as it was. It runs in two phases under `_mtx`: @@ -452,7 +467,7 @@ make teardown order-independent.) | `registerHandler(binding)` | `void registerHandler(const shared_ptr&)` | Pre-built binding. | | `switchBackend` | `void switchBackend(unique_ptr)` | Atomic: stages all re-registrations on the new backend, commits (publishes new ids + swaps) only if all succeed, else rolls back and rethrows leaving old backend + `currentId`s intact. Cancels old backend's pending ops with `BackendChangedError`. | | `deregisterHandler` | `void deregisterHandler(const shared_ptr&)` | Deregisters from active backend (if bound), resets `currentId` to 0, removes from tracking. | -| `executeVia` | `Completion executeVia(const shared_ptr&, Action, IExecutor*)` | Lock-free dispatch. Attaches default session. On `LocalBackend`, rejects an action whose `ActionValidator::ready` returns `false` with `morph::model::ValidationError` via `onError`, before `Model::execute` runs. Records journal for loggable actions. Value-forwarding into the typed `Completion` is `try`/`catch`-guarded — a throwing result move/copy resolves the completion via `onError` instead of hanging or terminating. | +| `executeVia` | `Completion executeVia(const shared_ptr&, Action, IExecutor*)` | Lock-free dispatch. Attaches default session. On `LocalBackend`, rejects an action whose `ActionValidator::ready` returns `false` with `morph::model::ValidationError` via `onError`, before `Model::execute` runs. Records journal for loggable actions. Value-forwarding into the typed `Completion` is `try`/`catch`-guarded — a throwing result move/copy resolves the completion via `onError` instead of hanging or terminating. The bridge-touching side effects (`onResult`, `hasSubscribers()`/`publishResult`) are gated on the `_liveness` token, checked before either runs, so a completion resolving after `~Bridge()` skips them instead of touching the dangling `Bridge`. | | `setDefaultSession` | `void setDefaultSession(session::Context)` | Installs default session context. | | `defaultSession` | `session::Context defaultSession() const` | Returns snapshot of default session. | From da25faadc38abc683f8afe32b48aedc23f24d09c Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 12:27:07 +0300 Subject: [PATCH 26/42] test(bridge): strengthen FIX 5 regression coverage per review Replace the single lifetime test with two, addressing review feedback that the original provided no protection outside a sanitizer build: - A new deterministic test covers the onResult half of the bug (previously uncovered): pre-fix, onResult ran completely unconditionally, so a flag set as its first statement -- before it touches the dangling bridge via a captured raw pointer, mirroring BridgeHandler::execute's ResultKeyed branch -- differs pre/post fix regardless of memory contents. Verified empirically: fails 10/10 runs against the reverted pre-fix ordering, passes 5/5 against the fix. - The hasSubscribers() half is now an explicitly-labeled best-effort probe (heap-allocated bridge, freed memory scribbled to raise reuse odds) rather than presented as a regression guard: verified empirically that it does not reliably fail pre-fix (6/6 clean passes against the reverted ordering), because the final branch outcome is identical pre/post fix in a sequential, single-threaded destroy-then-resolve test -- only a sanitizer or a genuine concurrent race can observe the difference. Also confirmed this machine's AppleClang ASan+UBSan combination hangs even on --list-tests (no test execution at all), independent of anything this task touched, so that route is unavailable here. --- tests/test_bridge_lifetime.cpp | 159 +++++++++++++++++++++++++++------ 1 file changed, 132 insertions(+), 27 deletions(-) diff --git a/tests/test_bridge_lifetime.cpp b/tests/test_bridge_lifetime.cpp index 26926c5d..a6f711f2 100644 --- a/tests/test_bridge_lifetime.cpp +++ b/tests/test_bridge_lifetime.cpp @@ -13,10 +13,20 @@ // BEFORE touching anything that reaches into the bridge (`onResult` // and `hasSubscribers()`), so a backend completion that resolves // after `~Bridge()` has run does not dereference the dangling -// `Bridge`. +// `Bridge`. Two test cases cover the two guarded call sites with +// different strength: the `onResult` case is a deterministic, +// sanitizer-free regression test (pre-fix, `onResult` ran with no +// guard at all, so a flag set as its first statement differs +// pre/post fix regardless of memory contents); the +// `hasSubscribers()` case is a best-effort probe only -- see its +// comment for why a sequential, single-threaded test structurally +// cannot observe a behavioural difference there without a working +// sanitizer. #include #include +#include +#include #include #include #include @@ -24,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -279,37 +290,131 @@ TEST_CASE("executeVia routes a throwing result move to onError instead of hangin // ── FIX 5 ──────────────────────────────────────────────────────────────────── -TEST_CASE("Bridge: a completion resolving after the bridge is destroyed does not touch the dead bridge", +TEST_CASE("Bridge: onResult does not run once the bridge is destroyed", "[bridge][lifetime]") { + // Mirrors BridgeHandler::execute's ResultKeyed branch (bridge.hpp, the + // `if constexpr (kShared && ResultKeyed)` case): a non-empty + // `onResult` callback that calls back into the bridge through a captured + // raw pointer to adopt a result-sourced primary key + // (`bridgePtr->assignHandlerPrimary(...)`) -- the exact shape of + // the second bridge-touching side effect FIX 5 guards, and the more + // severe half of the original bug: pre-fix, `onResult` ran completely + // unconditionally, with no liveness check at all. + // + // This half is fully deterministic to detect even without a sanitizer. + // The lambda's first statement -- `onResultRan.store(true)` -- touches + // only a local test variable, not the bridge, so it is always safely + // observable if `onResult` is invoked at all, regardless of what the + // subsequent (genuinely dangerous) bridge access does. Pre-fix, the + // unconditional call means this flag always ends up `true`. Post-fix, the + // `onResult && bridgeAlive` guard means `onResult` -- and therefore this + // lambda -- never runs at all once the bridge is gone, so the flag stays + // `false`. If the dangerous call after it crashes the process on a + // pre-fix build (locking/copying the destroyed bridge's `_mtx`/`_backend` + // members), Catch2's fatal-signal handling reports that as a failure too + // -- either outcome (assertion failure or a crash) correctly fails this + // test against the pre-fix ordering. + auto backendOwner = std::make_unique(); + auto* const backendPtr = backendOwner.get(); + morph::testing::InlineExecutor cbExec; + + // Heap-allocated (not stack-local via RAII scope exit) so the freed + // memory actually goes back to the allocator instead of merely leaving a + // stack frame whose bytes nothing has touched yet. + auto* bridge = new morph::bridge::Bridge(std::move(backendOwner)); + + auto binding = std::make_shared(); + binding->typeId = "BL_DeferredModel"; + binding->modelFactory = [] { return morph::model::detail::ModelFactory::create(); }; + bridge->registerHandler(binding); + + std::atomic onResultRan{false}; + auto completion = bridge->executeVia( + binding, DeferredAction{}, &cbExec, [bridge, binding, &onResultRan](const int&) { + onResultRan.store(true); + bridge->assignHandlerPrimary(binding, "42"); + }); + (void)completion; + + auto pending = backendPtr->state; + REQUIRE(pending); + + delete bridge; // ~Bridge runs here; `bridge` is now a dangling pointer. + bridge = nullptr; + + pending->setValue(std::static_pointer_cast(std::make_shared(42))); + REQUIRE_FALSE(onResultRan.load()); +} + +TEST_CASE("Bridge: hasSubscribers is not read once the bridge is destroyed (best-effort probe)", "[bridge][lifetime]") { + // Unlike the onResult case above, this half of FIX 5 cannot be turned + // into a deterministic plain-build regression test. `hasSubscribers()` + // only reads a trivially-destructible `std::atomic`, and -- once + // the bridge really is fully destroyed before the completion resolves -- + // the surviving `!alive.expired()` half of the old + // `hasSubscribers() && !alive.expired()` condition is `false` regardless + // of which operand is evaluated first. So the *observable branch + // outcome* (whether `publishResult` runs) is identical pre- and post-fix + // in a sequential, single-threaded destroy-then-resolve test like this + // one: no postcondition assertion can tell the two orderings apart here. + // The actual bug this guard exists for is either (a) the read of freed + // memory being itself undefined behaviour -- detectable only by a + // memory-safety tool such as ASan -- or (b) a genuine data race where the + // completion's callback runs concurrently with `~Bridge()` on another + // thread, which a sequential test cannot reproduce at all (that needs + // TSan plus real concurrency). + // + // We investigated using a sanitizer here: both the whole `morph_tests` + // binary and this file's tests in isolation hang indefinitely under this + // machine's AppleClang ASan+UBSan combination (confirmed against + // unrelated, already-passing tests too -- see the task report), so that + // route is not available in this environment. This test is therefore a + // best-effort probe, not a regression guard: heap-allocating the bridge + // and aggressively overwriting freed memory with a recognizable, + // non-zero pattern raises -- but does not guarantee -- the odds that a + // pre-fix read of the dangling `this` behaves observably differently + // (e.g. a crash while walking a corrupted `_subscriptions` vector, if + // `hasSubscribers()` happens to read back a nonzero count from reused + // memory). Confirmed empirically that it does NOT reliably fail against + // the pre-fix ordering (see the report); do not read this test as proof + // of coverage the way the onResult test above is. auto backendOwner = std::make_unique(); auto* const backendPtr = backendOwner.get(); morph::testing::InlineExecutor cbExec; - std::shared_ptr>> pending; - { - morph::bridge::Bridge bridge{std::move(backendOwner)}; - - auto binding = std::make_shared(); - binding->typeId = "BL_DeferredModel"; - binding->modelFactory = [] { return morph::model::detail::ModelFactory::create(); }; - bridge.registerHandler(binding); - - // Kicks off executeVia's `.then` chain against `bridge`, but the fake - // backend never resolves it -- the completion (and the `this`-capturing - // closure attached to it) is still pending when `bridge` goes out of - // scope below. - auto completion = bridge.executeVia(binding, DeferredAction{}, &cbExec); - (void)completion; - - pending = backendPtr->state; - REQUIRE(pending); - // ~Bridge runs here, with the completion still unresolved. + auto* bridge = new morph::bridge::Bridge(std::move(backendOwner)); + auto binding = std::make_shared(); + binding->typeId = "BL_DeferredModel"; + binding->modelFactory = [] { return morph::model::detail::ModelFactory::create(); }; + bridge->registerHandler(binding); + + // No onResult here -- this case isolates the hasSubscribers()/ + // publishResult side effect from the onResult side effect covered above. + auto completion = bridge->executeVia(binding, DeferredAction{}, &cbExec); + (void)completion; + + auto pending = backendPtr->state; + REQUIRE(pending); + + delete bridge; // ~Bridge runs here; `bridge` is now a dangling pointer. + bridge = nullptr; + + // Raise the odds the allocator hands the freed Bridge's memory back for + // one of these same-size allocations, filled with a distinct, non-zero + // byte pattern rather than bytes nothing has ever written to. + constexpr std::size_t kBridgeSize = sizeof(morph::bridge::Bridge); + constexpr int kFillAttempts = 16; + for (int i = 0; i < kFillAttempts; ++i) { + auto* const filler = static_cast(::operator new(kBridgeSize)); + std::memset(filler, 0xAA, kBridgeSize); + // Deliberately leaked for the rest of the test process -- freeing it + // immediately would just hand the same memory straight back on the + // next iteration, defeating the point of trying several attempts. } - // The Bridge above is already destroyed. Resolving the backend's completion - // now must not crash or touch freed memory -- this is exactly the - // "completion resolves after the Bridge is gone" race executeVia's liveness - // guard exists for. Catch2 reports a crash (e.g. under ASan) as a test - // failure/signal, so no further assertion is needed beyond reaching SUCCEED. + + // Must not crash even if hasSubscribers() does read back the freed, + // now-scribbled memory. See the comment above for why this is the + // strongest available check without a working sanitizer. pending->setValue(std::static_pointer_cast(std::make_shared(42))); - SUCCEED("resolving a completion after Bridge destruction did not touch the dead bridge"); + SUCCEED("resolving after destruction did not crash (best-effort probe; see comment above)"); } From 1bb4cebb10409d2ee0f5db5c663341b3c51889e3 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 12:51:37 +0300 Subject: [PATCH 27/42] test(bridge): make the hasSubscribers() probe a deterministic guard-page death test Replace the heap-reuse best-effort probe with one that places the Bridge on an mmap'd page, destroys it in place, then mprotect's the page to PROT_NONE. Pre-fix, hasSubscribers() dereferences the protected page and faults deterministically instead of possibly reading stale-but-intact memory. The fault is recovered in-process via a sigsetjmp/siglongjmp handler (installed for both SIGSEGV and SIGBUS -- this machine's Darwin kernel delivers SIGBUS for a PROT_NONE violation, which Catch2's own signal-handler list does not include) and converted into a normal, explicit FAIL(), so a regression here reports as one attributable failed test case rather than killing the whole process. POSIX-only; compiled out on Windows. Verified empirically: fails 10/10 runs (clean "FAILED: hasSubscribers() touched the destroyed bridge..." via caught signal 10) against the reverted pre-fix ordering, passes 5/5 individually and together with the rest of [bridge][lifetime] against the fix. Full suite (801 cases, 8234 assertions) passes in one process post-fix. --- tests/test_bridge_lifetime.cpp | 210 +++++++++++++++++++++++---------- 1 file changed, 149 insertions(+), 61 deletions(-) diff --git a/tests/test_bridge_lifetime.cpp b/tests/test_bridge_lifetime.cpp index a6f711f2..e1d7a197 100644 --- a/tests/test_bridge_lifetime.cpp +++ b/tests/test_bridge_lifetime.cpp @@ -13,20 +13,22 @@ // BEFORE touching anything that reaches into the bridge (`onResult` // and `hasSubscribers()`), so a backend completion that resolves // after `~Bridge()` has run does not dereference the dangling -// `Bridge`. Two test cases cover the two guarded call sites with -// different strength: the `onResult` case is a deterministic, -// sanitizer-free regression test (pre-fix, `onResult` ran with no -// guard at all, so a flag set as its first statement differs -// pre/post fix regardless of memory contents); the -// `hasSubscribers()` case is a best-effort probe only -- see its -// comment for why a sequential, single-threaded test structurally -// cannot observe a behavioural difference there without a working -// sanitizer. +// `Bridge`. Two test cases cover the two guarded call sites, both +// deterministic: the `onResult` case observes a flag set as the +// very first statement of a callback that pre-fix ran completely +// unguarded; the `hasSubscribers()` case (POSIX-only) places the +// Bridge on an `mmap`'d guard page, destroys it in place, then +// `mprotect`s the page to `PROT_NONE` -- pre-fix, `hasSubscribers()` +// dereferences the protected page and faults (SIGSEGV or, on this +// machine's Darwin kernel, SIGBUS), which the test's own +// `sigsetjmp`/`siglongjmp`-based handler converts into a normal, +// reported Catch2 test failure; post-fix, the liveness check gates +// the call out before the page is ever touched. #include #include #include -#include +#include #include #include #include @@ -39,6 +41,13 @@ #include #include +#if !defined(_WIN32) +#include +#include +#include +#include +#endif + #include "test_support.hpp" namespace { @@ -184,6 +193,28 @@ class DeferredResultBackend : public morph::backend::detail::IBackend { std::shared_ptr>> state; }; +#if !defined(_WIN32) +// Recovery machinery for the guard-page death test below. A signal handler +// may only call async-signal-safe functions, so this does the minimum: record +// which signal fired in a `sig_atomic_t` and jump back to the `sigsetjmp` +// checkpoint in the test body via `siglongjmp` (which, unlike plain +// `longjmp`, also restores the signal mask the jump point had -- required so +// the signal being handled isn't left permanently blocked after we resume). +// +// PROT_NONE protection faults are not portable across POSIX platforms: this +// machine's AppleClang/Darwin delivers SIGBUS for them (confirmed +// empirically -- see the report), while Linux typically delivers SIGSEGV for +// the same fault. Both are installed with the same handler so the test works +// either way. +volatile std::sig_atomic_t gGuardPageFaultSignal = 0; +sigjmp_buf gGuardPageJumpBuf; + +void guardPageFaultHandler(int sig) { + gGuardPageFaultSignal = sig; + siglongjmp(gGuardPageJumpBuf, 1); +} +#endif + } // namespace template <> @@ -345,76 +376,133 @@ TEST_CASE("Bridge: onResult does not run once the bridge is destroyed", "[bridge REQUIRE_FALSE(onResultRan.load()); } -TEST_CASE("Bridge: hasSubscribers is not read once the bridge is destroyed (best-effort probe)", +#if !defined(_WIN32) +// POSIX-only (mmap/mprotect): the guard-page death test below relies on them, +// so it is compiled out on Windows rather than approximated with something +// weaker there. See its comment for the technique and why it is deterministic +// where the superseded heap-reuse probe this replaced was not. +TEST_CASE("Bridge: hasSubscribers is not read once the bridge is destroyed (guard-page death test)", "[bridge][lifetime]") { - // Unlike the onResult case above, this half of FIX 5 cannot be turned - // into a deterministic plain-build regression test. `hasSubscribers()` - // only reads a trivially-destructible `std::atomic`, and -- once - // the bridge really is fully destroyed before the completion resolves -- - // the surviving `!alive.expired()` half of the old - // `hasSubscribers() && !alive.expired()` condition is `false` regardless - // of which operand is evaluated first. So the *observable branch - // outcome* (whether `publishResult` runs) is identical pre- and post-fix - // in a sequential, single-threaded destroy-then-resolve test like this - // one: no postcondition assertion can tell the two orderings apart here. - // The actual bug this guard exists for is either (a) the read of freed - // memory being itself undefined behaviour -- detectable only by a - // memory-safety tool such as ASan -- or (b) a genuine data race where the - // completion's callback runs concurrently with `~Bridge()` on another - // thread, which a sequential test cannot reproduce at all (that needs - // TSan plus real concurrency). + // A prior version of this test heap-allocated the Bridge with plain + // `new`/`delete` and tried to raise the odds of observing a crash by + // scribbling over freed memory. That could not reliably fail pre-fix + // (confirmed empirically -- see the report): reading a freed-but-still + // mapped `std::atomic` is undefined behaviour, but not something + // that reliably *faults*, and downstream of that read the surviving + // `!alive.expired()` half of the pre-fix `hasSubscribers() && + // !alive.expired()` condition is false regardless of evaluation order -- + // so `publishResult()` never actually ran in either ordering, leaving no + // difference for a postcondition assertion to observe. // - // We investigated using a sanitizer here: both the whole `morph_tests` - // binary and this file's tests in isolation hang indefinitely under this - // machine's AppleClang ASan+UBSan combination (confirmed against - // unrelated, already-passing tests too -- see the task report), so that - // route is not available in this environment. This test is therefore a - // best-effort probe, not a regression guard: heap-allocating the bridge - // and aggressively overwriting freed memory with a recognizable, - // non-zero pattern raises -- but does not guarantee -- the odds that a - // pre-fix read of the dangling `this` behaves observably differently - // (e.g. a crash while walking a corrupted `_subscriptions` vector, if - // `hasSubscribers()` happens to read back a nonzero count from reused - // memory). Confirmed empirically that it does NOT reliably fail against - // the pre-fix ordering (see the report); do not read this test as proof - // of coverage the way the onResult test above is. + // This version instead makes the touch of the dangling `this` itself + // fault, deterministically: the Bridge is placement-new'd inside a page + // obtained via `mmap`, manually destroyed in place (not `delete` -- the + // memory isn't heap-owned), and the page is then `mprotect`'d to + // `PROT_NONE`. `hasSubscribers()` is a member function call that + // dereferences `this` to read `_subscriptionCount`; pre-fix, that + // dereference lands on a `PROT_NONE` page and faults immediately, before + // it can return any value at all. + // + // The fault is recovered in-process via `sigsetjmp`/`siglongjmp` (see + // `guardPageFaultHandler` above) rather than relying on Catch2's built-in + // fatal-signal handler: empirically (see the report), a PROT_NONE + // protection fault on this machine's Darwin kernel delivers **SIGBUS**, + // not SIGSEGV -- and Catch2's POSIX handler list is SIGINT/SIGILL/ + // SIGFPE/SIGSEGV/SIGTERM/SIGABRT, which does not include SIGBUS, so an + // uncaught SIGBUS would kill the whole test *process* (not just this test + // case) with no Catch2 report at all. Installing our own handler for both + // SIGSEGV and SIGBUS and jumping back into ordinary control flow converts + // either one into a normal, explicit `FAIL(...)` -- a clean, attributable + // Catch2 failure for this one test case, and (unlike an uncaught signal) + // safe to run alongside every other test in one process. Post-fix, + // `bridgeAlive` is checked first and is false, so `hasSubscribers()` is + // never called at all -- the protected page is never touched, no signal + // fires, and the test runs through to a clean pass. auto backendOwner = std::make_unique(); auto* const backendPtr = backendOwner.get(); morph::testing::InlineExecutor cbExec; - auto* bridge = new morph::bridge::Bridge(std::move(backendOwner)); + long const pageSizeRaw = sysconf(_SC_PAGESIZE); + REQUIRE(pageSizeRaw > 0); + auto const pageSize = static_cast(pageSizeRaw); + REQUIRE(sizeof(morph::bridge::Bridge) <= pageSize); + + void* const region = mmap(nullptr, pageSize, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + REQUIRE(region != MAP_FAILED); + + // Place the Bridge flush against the end of the page (rounded down to its + // required alignment), so a touch of any of its member data lands inside + // the page that gets protected below, not in whatever precedes it. + auto const regionEnd = reinterpret_cast(region) + pageSize; + auto objAddr = regionEnd - sizeof(morph::bridge::Bridge); + objAddr -= objAddr % alignof(morph::bridge::Bridge); + void* const bridgeMem = reinterpret_cast(objAddr); // NOLINT(performance-no-int-to-ptr) + + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) -- placement new into + // the mmap'd region above; destroyed via an explicit dtor call below, not + // `delete` (the memory is not heap-owned). + auto* const bridge = new (bridgeMem) morph::bridge::Bridge(std::move(backendOwner)); + auto binding = std::make_shared(); binding->typeId = "BL_DeferredModel"; binding->modelFactory = [] { return morph::model::detail::ModelFactory::create(); }; bridge->registerHandler(binding); // No onResult here -- this case isolates the hasSubscribers()/ - // publishResult side effect from the onResult side effect covered above. + // publishResult side effect from the onResult side effect covered by the + // test above. auto completion = bridge->executeVia(binding, DeferredAction{}, &cbExec); (void)completion; auto pending = backendPtr->state; REQUIRE(pending); - delete bridge; // ~Bridge runs here; `bridge` is now a dangling pointer. - bridge = nullptr; + bridge->~Bridge(); // Manually destroyed in place -- see the comment above on why not `delete`. + + REQUIRE(mprotect(region, pageSize, PROT_NONE) == 0); + + struct sigaction sa {}; + sa.sa_handler = guardPageFaultHandler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; + struct sigaction oldSegv {}; + struct sigaction oldBus {}; + REQUIRE(sigaction(SIGSEGV, &sa, &oldSegv) == 0); + REQUIRE(sigaction(SIGBUS, &sa, &oldBus) == 0); + + gGuardPageFaultSignal = 0; + // sigsetjmp(..., 1) saves the signal mask along with the jump point, so + // siglongjmp restores it too -- required so the signal we just caught + // isn't left blocked for the rest of the process after we resume here. + // A nonzero return means we got here via siglongjmp from the handler + // (i.e. a fault happened); a zero return means the call below is about + // to run for the first time. + bool const faulted = sigsetjmp(gGuardPageJumpBuf, 1) != 0; + if (!faulted) { + // Pre-fix: hasSubscribers() dereferences the now-protected `this`, + // faults, and control jumps straight to the `faulted` branch below + // instead of returning here. Post-fix: bridgeAlive gates + // hasSubscribers() out entirely, so this resolves and returns + // normally, and `faulted` stays false. + pending->setValue(std::static_pointer_cast(std::make_shared(42))); + } - // Raise the odds the allocator hands the freed Bridge's memory back for - // one of these same-size allocations, filled with a distinct, non-zero - // byte pattern rather than bytes nothing has ever written to. - constexpr std::size_t kBridgeSize = sizeof(morph::bridge::Bridge); - constexpr int kFillAttempts = 16; - for (int i = 0; i < kFillAttempts; ++i) { - auto* const filler = static_cast(::operator new(kBridgeSize)); - std::memset(filler, 0xAA, kBridgeSize); - // Deliberately leaked for the rest of the test process -- freeing it - // immediately would just hand the same memory straight back on the - // next iteration, defeating the point of trying several attempts. + // Restore the default handlers before doing anything else, whether or not + // we faulted. + sigaction(SIGSEGV, &oldSegv, nullptr); + sigaction(SIGBUS, &oldBus, nullptr); + + if (faulted) { + // The crash happened inside a single, lock-free atomic load + // (hasSubscribers() takes no locks), so nothing was left mid-mutation + // for this best-effort cleanup to worry about disturbing. + munmap(region, pageSize); + std::string const message = "hasSubscribers() touched the destroyed bridge after ~Bridge() ran " + "(caught signal " + + std::to_string(gGuardPageFaultSignal) + ")"; + FAIL(message); } - // Must not crash even if hasSubscribers() does read back the freed, - // now-scribbled memory. See the comment above for why this is the - // strongest available check without a working sanitizer. - pending->setValue(std::static_pointer_cast(std::make_shared(42))); - SUCCEED("resolving after destruction did not crash (best-effort probe; see comment above)"); + REQUIRE(munmap(region, pageSize) == 0); } +#endif // !defined(_WIN32) From 2c7a7c93f760a14c3f11cefd6ecc84c53a642a7f Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 13:02:34 +0300 Subject: [PATCH 28/42] fix(remote): deregister releases the requesting connection's own scope entry --- docs/spec/core/backend.md | 22 ++++++---- include/morph/core/remote.hpp | 40 +++++------------- tests/test_remote_connection_scope.cpp | 57 ++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 37 deletions(-) diff --git a/docs/spec/core/backend.md b/docs/spec/core/backend.md index 259eacb2..00837adf 100644 --- a/docs/spec/core/backend.md +++ b/docs/spec/core/backend.md @@ -312,13 +312,21 @@ nothing for a caller that never uses it. - `openConnection()` returns a fresh non-zero `ConnectionId` and opens an empty scope for it. Call once per accepted transport connection. -- The scoped `handle(msg, reply, cid)` overload attributes any `register` - decoded from `msg` to `cid`'s scope: the new `ModelId` is recorded in both a - `cid → set` map and a `ModelId → ConnectionId` map, next to - `_models`/`_owners` under the same `_regMtx`, so scope membership can never - desync from instance existence. A `deregister` (via either entry point) - removes the id from its scope as well as from `_models`/`_owners`, so a - later `closeConnection` never double-erases it. +- The scoped `handle(msg, reply, cid)` overload attributes any `register` (or + register-or-attach `attach`) decoded from `msg` to `cid`'s scope: the + `ModelId` is recorded in a `cid → (ModelId → count)` map, next to + `_models`/`_owners`/the shared-instance directory under the same `_regMtx`, + so scope membership can never desync from instance existence. The count + lets one connection hold more than one reference to the same shared + instance (e.g. two handlers on one connection attaching the same key) + without either reference leaking the other's release. +- A `deregister` releases exactly the reference **the requesting connection** + holds — decrementing `cid`'s own scope entry for that `ModelId`, using the + `cid` the deregister call itself carries, never whichever connection + happened to attach the instance last. A shared instance can have several + owning connections at once; crediting the release to the wrong one would + either strand a reference no one will ever decrement, or let one + connection's deregister silently consume another's hold. - `closeConnection(cid)` erases every model still recorded in `cid`'s scope (`_models`, `_owners`, and the per-instance connection entry) exactly as the `deregister` path does, then drops the scope itself. Passing `0`, an diff --git a/include/morph/core/remote.hpp b/include/morph/core/remote.hpp index 1cc811d4..b6b2e2b6 100644 --- a/include/morph/core/remote.hpp +++ b/include/morph/core/remote.hpp @@ -436,7 +436,6 @@ class RemoteServer : public std::enable_shared_from_this { for (std::size_t idx = 0; idx < refs; ++idx) { releaseInstanceLocked(mid); } - _modelConnection.erase(mid); } _connectionScopes.erase(scopeIter); } @@ -631,9 +630,7 @@ class RemoteServer : public std::enable_shared_from_this { } } } - if (releaseInstanceLocked(mid)) { - _modelConnection.erase(mid); - } + releaseInstanceLocked(mid); } /// @brief Records a new attachment of @p mid to @p cid. Caller holds `_regMtx`. @@ -649,7 +646,6 @@ class RemoteServer : public std::enable_shared_from_this { return false; } scopeIter->second[mid] += 1; - _modelConnection[mid] = cid; return true; } @@ -930,7 +926,6 @@ class RemoteServer : public std::enable_shared_from_this { scopeAlreadyClosed = true; } else { scopeIter->second[mid] += 1; - _modelConnection[mid] = cid; } } if (!overLiveModelCap && !scopeAlreadyClosed) { @@ -1033,26 +1028,15 @@ class RemoteServer : public std::enable_shared_from_this { } { std::scoped_lock const lock{_regMtx}; - releaseInstanceLocked(mid); - // Keep the connection scope's membership in sync: an - // explicit wire deregister drops one of this connection's - // references, so a later closeConnection never - // double-releases it. - if (auto connIter = _modelConnection.find(mid); connIter != _modelConnection.end()) { - if (auto scopeIter = _connectionScopes.find(connIter->second); - scopeIter != _connectionScopes.end()) { - auto refIter = scopeIter->second.find(mid); - if (refIter != scopeIter->second.end()) { - refIter->second -= 1; - if (refIter->second == 0) { - scopeIter->second.erase(refIter); - } - } - } - if (!_models.contains(mid)) { - _modelConnection.erase(connIter); - } - } + // Release exactly the reference *this* connection (`cid`, + // the scope the deregister request itself carries) holds, + // not whichever connection happened to attach the + // instance last -- a shared instance may have several + // owning connections at once, and crediting the release + // to the wrong one either strands a reference nobody will + // ever decrement, or lets one connection's deregister + // silently consume another's hold. + releaseScopedLocked(mid, cid); } reply(::morph::wire::encode(::morph::wire::makeOk(env.callId))); } else if (env.kind == "execute") { @@ -1323,10 +1307,6 @@ class RemoteServer : public std::enable_shared_from_this { std::unordered_map> _connectionScopes; - // Owning connection recorded per scoped instance; absent means unscoped - // (registered via the two-argument handle()/handleInline()). - std::unordered_map<::morph::exec::detail::ModelId, ConnectionId, ::morph::exec::detail::ModelIdHash> - _modelConnection; // Shared-instance directory: (typeId, primary) -> ModelId, its reverse, and // the cross-connection attach count. Guarded by _regMtx alongside // _models/_owners so directory membership can never desync from instance diff --git a/tests/test_remote_connection_scope.cpp b/tests/test_remote_connection_scope.cpp index da58f747..7aa9529e 100644 --- a/tests/test_remote_connection_scope.cpp +++ b/tests/test_remote_connection_scope.cpp @@ -282,6 +282,63 @@ TEST_CASE("morph::backend::RemoteServer: connection scopes are isolated from one REQUIRE(waiterB.env.body == "9"); } +TEST_CASE( + "morph::backend::RemoteServer: deregister releases the requesting connection's own reference, not " + "whichever connection attached last", + "[remote][connection-scope]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto& env = csEnv(); + auto server = std::make_shared(pool, env.dispatcher, env.registry); + + auto cidA = server->openConnection(); + auto cidB = server->openConnection(); + + WaitReply regA; + server->handle(morph::wire::encode(morph::wire::makeRegisterShared("CS_SquareModel", "42")), std::ref(regA), + cidA); + REQUIRE(regA.await()); + REQUIRE(regA.env.kind == "ok"); + auto modelId = regA.env.modelId; + + // B attaches the same shared key -- the connection that "last touched" + // the instance, exactly the case a single-owner ModelId->ConnectionId map + // would misattribute the instance to. + WaitReply regB; + server->handle(morph::wire::encode(morph::wire::makeRegisterShared("CS_SquareModel", "42")), std::ref(regB), + cidB); + REQUIRE(regB.await()); + REQUIRE(regB.env.modelId == modelId); + + // A releases its own reference explicitly. + WaitReply dereg; + server->handle(morph::wire::encode(morph::wire::makeDeregister(modelId)), std::ref(dereg), cidA); + REQUIRE(dereg.await()); + REQUIRE(dereg.env.kind == "ok"); + + // The instance must still be reachable -- B's reference is still live. + morph::wire::Envelope execReq; + execReq.kind = "execute"; + execReq.modelId = modelId; + execReq.modelType = "CS_SquareModel"; + execReq.actionType = "CS_SquareAction"; + execReq.body = R"({"x":3})"; + WaitReply stillAlive; + server->handle(morph::wire::encode(execReq), std::ref(stillAlive)); + REQUIRE(stillAlive.await()); + REQUIRE(stillAlive.env.kind == "ok"); + + // Closing B's connection must release B's own reference and destroy the + // instance -- it must not find its scope entry already (wrongly) cleared + // by A's earlier deregister. + server->closeConnection(cidB); + + WaitReply gone; + server->handle(morph::wire::encode(execReq), std::ref(gone)); + REQUIRE(gone.await()); + REQUIRE(gone.env.kind == "err"); + REQUIRE(gone.env.message == "model not found"); +} + TEST_CASE("morph::backend::RemoteServer: the unscoped two-argument handle() never populates any connection scope", "[remote][connection-scope][regression]") { morph::exec::ThreadPoolExecutor pool{2}; From 3f293df8646c5767c577aa33f3cc1c85d1d8f727 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 13:09:15 +0300 Subject: [PATCH 29/42] fix(wire): carry contextKey on attach, not only register makeAttach had no contextKey parameter, so an instance created via its first attach (rather than a shared register) never reached a configured LogProvider -- the entity's stable identity was silently dropped on the wire. Thread identity.contextKey through the three attachModel call sites (SimulatedRemoteBackend, SocketBackend, QtWebSocketBackend). --- docs/spec/core/wire.md | 8 ++++++-- include/morph/core/remote.hpp | 4 ++-- include/morph/core/wire.hpp | 22 ++++++++++++++-------- include/morph/net/socket_backend.hpp | 4 +++- src/qt/qt_websocket_backend.cpp | 3 ++- tests/test_shared_instances.cpp | 21 +++++++++++++++++++++ 6 files changed, 48 insertions(+), 14 deletions(-) diff --git a/docs/spec/core/wire.md b/docs/spec/core/wire.md index 55bc2960..4ad7cf8e 100644 --- a/docs/spec/core/wire.md +++ b/docs/spec/core/wire.md @@ -36,8 +36,12 @@ their `kind` needs and leave the rest as default-constructed values. `contextKey` is an optional stable identity for the new instance (e.g. an account id). When present, the server-side holder gets an action log attached (if a -`LogProvider` is configured). When empty, no action log is attached. Ignored on -every kind other than `"register"`. +`LogProvider` is configured). When empty, no action log is attached. Carried on +`"register"` and `"attach"` — see +[shared_instances.md](shared_instances.md#the-instance-directory) — so an +instance created by its *first* `attach` (rather than a shared `register`) +gets a log attached exactly as one created via `register` would. Ignored on +every other kind. ### `session` — authorization context diff --git a/include/morph/core/remote.hpp b/include/morph/core/remote.hpp index b6b2e2b6..25acbd23 100644 --- a/include/morph/core/remote.hpp +++ b/include/morph/core/remote.hpp @@ -1444,8 +1444,8 @@ class SimulatedRemoteBackend : public detail::IBackend { } return registerModelWithContext(typeId, std::move(factory), identity.contextKey); } - auto reply = ::morph::wire::decode(_server.handleInline( - ::morph::wire::encode(::morph::wire::makeAttach(typeId, std::string{identity.primary}, current.v)))); + auto reply = ::morph::wire::decode(_server.handleInline(::morph::wire::encode(::morph::wire::makeAttach( + typeId, std::string{identity.primary}, current.v, std::string{identity.contextKey})))); if (reply.kind == "ok") { return ::morph::exec::detail::ModelId{reply.modelId}; } diff --git a/include/morph/core/wire.hpp b/include/morph/core/wire.hpp index 43cb6180..b9a7f3de 100644 --- a/include/morph/core/wire.hpp +++ b/include/morph/core/wire.hpp @@ -71,10 +71,11 @@ struct Envelope { /// @brief Model type id for `register`. std::string typeId; - /// @brief Stable identity of the model instance being registered (e.g. an - /// account id). Empty means "no identity" — the server-side holder - /// gets no action log attached even if a `LogProvider` is configured. - /// Ignored on every kind other than `register`. + /// @brief Stable identity of the model instance being registered or + /// attached to (e.g. an account id). Empty means "no identity" — + /// the server-side holder gets no action log attached even if a + /// `LogProvider` is configured. Carried on `register` and + /// `attach`; ignored on every other kind. std::string contextKey; /// @brief Primary key of the instance being registered or attached to. @@ -183,15 +184,20 @@ inline Envelope makeRegisterShared(std::string typeId, std::string primary, std: /// so a re-pointing handler cannot lose its slot to `LimitPolicy::maxLiveModels` /// between releasing the old instance and acquiring the new one. /// -/// @param typeId Model type id. -/// @param primary Canonical string encoding of the primary key to attach to. -/// @param modelId Instance the client is currently attached to; `0` if none. -inline Envelope makeAttach(std::string typeId, std::string primary, uint64_t modelId = 0) { +/// @param typeId Model type id. +/// @param primary Canonical string encoding of the primary key to attach to. +/// @param modelId Instance the client is currently attached to; `0` if none. +/// @param contextKey Optional entity key for the action log, carried the same +/// way `register` carries it, so an instance created via +/// its *first* `attach` (rather than a shared `register`) +/// still gets a configured `LogProvider`'s log attached. +inline Envelope makeAttach(std::string typeId, std::string primary, uint64_t modelId = 0, std::string contextKey = {}) { Envelope env; env.kind = "attach"; env.typeId = std::move(typeId); env.primary = std::move(primary); env.modelId = modelId; + env.contextKey = std::move(contextKey); env.shared = true; return env; } diff --git a/include/morph/net/socket_backend.hpp b/include/morph/net/socket_backend.hpp index aed70393..fc9e42a8 100644 --- a/include/morph/net/socket_backend.hpp +++ b/include/morph/net/socket_backend.hpp @@ -181,7 +181,9 @@ class SocketBackend : public ::morph::backend::detail::IBackend { } return registerModelWithContext(typeId, std::move(factory), identity.contextKey); } - return sendControlForId(::morph::wire::makeAttach(typeId, std::string{identity.primary}, current.v), "attach"); + return sendControlForId( + ::morph::wire::makeAttach(typeId, std::string{identity.primary}, current.v, std::string{identity.contextKey}), + "attach"); } /// @brief Files a live server-side instance under @p primary. diff --git a/src/qt/qt_websocket_backend.cpp b/src/qt/qt_websocket_backend.cpp index a258c34e..7a163a90 100644 --- a/src/qt/qt_websocket_backend.cpp +++ b/src/qt/qt_websocket_backend.cpp @@ -180,7 +180,8 @@ ::morph::exec::detail::ModelId QtWebSocketBackend::attachModel( return registerModelWithContext(typeId, std::move(factory), identity.contextKey); } return modelIdFromReply( - sendSync(::morph::wire::encode(::morph::wire::makeAttach(typeId, std::string{identity.primary}, current.v))), + sendSync(::morph::wire::encode(::morph::wire::makeAttach(typeId, std::string{identity.primary}, current.v, + std::string{identity.contextKey}))), "attach"); } diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp index 6c4a0b02..a309c400 100644 --- a/tests/test_shared_instances.cpp +++ b/tests/test_shared_instances.cpp @@ -801,6 +801,27 @@ TEST_CASE("the server re-files an instance onto a new key over the wire", "[shar REQUIRE(keys == std::vector{"new"}); } +TEST_CASE("an attach that creates the instance carries contextKey to a configured LogProvider", "[shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool); + + std::vector requestedFor; + server->setLogProvider([&](std::string_view modelType, std::string_view contextKey) { + requestedFor.emplace_back(std::string{modelType} + ":" + std::string{contextKey}); + return nullptr; + }); + + // The first touch of key "77" goes through `attach` (not a shared + // `register`) -- exercised directly at the wire level since + // wire::makeAttach previously had no contextKey parameter at all, so the + // entity's stable identity was silently dropped before it ever reached + // the server. + auto reply = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeAttach("SHI_CounterModel", "77", 0, "77")))); + REQUIRE(reply.kind == "ok"); + REQUIRE(requestedFor == std::vector{"SHI_CounterModel:77"}); +} + TEST_CASE("attach and instances stamp the verified principal", "[shared-instances]") { morph::exec::ThreadPoolExecutor pool{2}; auto server = std::make_shared(pool, std::make_shared()); From e5d3c1233be4ea8c724129ce1c511773517c127c Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 13:16:57 +0300 Subject: [PATCH 30/42] fix(bridge): route BridgeHandler::execute's attach failures through onError --- docs/spec/core/bridge.md | 2 +- include/morph/core/bridge.hpp | 53 +++++++++++++++++++++++++++++---- tests/test_shared_instances.cpp | 24 +++++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/docs/spec/core/bridge.md b/docs/spec/core/bridge.md index 73fdaf56..f2f75810 100644 --- a/docs/spec/core/bridge.md +++ b/docs/spec/core/bridge.md @@ -478,7 +478,7 @@ make teardown order-independent.) | ctor (default) | `BridgeHandler(Bridge&, IExecutor*)` | Registers via `Bridge::registerHandler()`. | | ctor (custom binding) | `BridgeHandler(Bridge&, IExecutor*, shared_ptr)` | Registers pre-built binding. | | dtor | `~BridgeHandler()` | Deregisters via `Bridge::deregisterHandler`, but only if the bridge's liveness token is still alive; a no-op if the `Bridge` was already destroyed. | -| `execute` | `Completion execute(Action)` | Typed dispatch through the bridge. | +| `execute` | `Completion execute(Action)` | Typed dispatch through the bridge. 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(...)`. | | `executeJson` | `Completion executeJson(string_view actionType, string_view bodyJson)` | Type-erased dispatch through `ActionExecuteRegistry`. | | `subscribe(cb)` | `void subscribe(function)` | Fire `cb` whenever an `R` is produced on the attached instance. | | `unsubscribe` | `void unsubscribe()` | Drops this handler's callback for `R`. | diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index 7cf44b15..6fce2691 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -930,25 +930,44 @@ class BridgeHandler { /// /// @tparam Action Concrete action type registered with `BRIDGE_REGISTER_ACTION`. /// @param action Action to execute (moved into the dispatch). - /// @return Completion that resolves on the GUI executor. + /// @return Completion that resolves on the GUI executor. A payload- or + /// result-keyed action's attach/promote step never throws out of + /// this call, even when the backend refuses it (e.g. a remote + /// server at `LimitPolicy::maxLiveModels`, a transport error, or + /// an unauthorized attach) — the failure is instead delivered + /// through the returned Completion's `.onError(...)`, exactly + /// like any other dispatch failure. template ::morph::async::Completion::Result> execute(Action action) { + using R = ::morph::model::ActionTraits::Result; if constexpr (kShared && ::morph::model::detail::PayloadKeyed) { // The action names its instance: attach (or re-point) before - // dispatching, so the call lands on the instance it asked for. - _bridge.template attachHandler(_binding, ::morph::model::ActionKeyTraits::key(action)); + // dispatching, so the call lands on the instance it asked for. A + // remote backend's refusal (LimitPolicy::maxLiveModels, a + // transport error, unauthorized) must surface through the + // returned Completion's onError, exactly like every other + // dispatch failure — not as a synchronous throw out of execute(). + try { + _bridge.template attachHandler(_binding, ::morph::model::ActionKeyTraits::key(action)); + } catch (...) { + return failedCompletion(std::current_exception()); + } } if constexpr (kShared && ::morph::model::detail::ResultKeyed) { // The action *creates* the instance and its result carries the // generated key, exactly as a database insert returns its primary // key. Adopt it before any user callback observes the result, so a // .then() can immediately run further actions on the new instance. - using R = ::morph::model::ActionTraits::Result; + // // The action generates its own key, so it must run before the key // exists. Give the handler an anonymous instance to run on, then // promote *that* instance once the reply names it — re-pointing to a // fresh one instead would strand whatever the create just did. - _bridge.ensureBound(_binding); + try { + _bridge.ensureBound(_binding); + } catch (...) { + return failedCompletion(std::current_exception()); + } auto* const bridgePtr = &_bridge; auto binding = _binding; return _bridge.template executeVia( @@ -976,6 +995,13 @@ class BridgeHandler { /// signature is instantiated lazily, since `PrimaryKeyOf` is /// ill-formed for an unkeyed model. /// @param key Primary key of the instance to attach to. + /// @throws std::runtime_error if the backend refuses the attach (e.g. a + /// remote server at `LimitPolicy::maxLiveModels`, a transport + /// error, or an unauthorized attach). `attach()` is a synchronous + /// `void` call with no `Completion` to route a failure through, + /// unlike `execute()`; a caller that wants the failure delivered + /// asynchronously should attach via a payload-keyed action's + /// `execute()` instead. template void attach(const ::morph::model::PrimaryKeyOf& key) requires kShared @@ -1098,6 +1124,23 @@ class BridgeHandler { [[nodiscard]] const std::shared_ptr& binding() const { return _binding; } private: + /// @brief Builds an already-failed `Completion`, resolved via `.onError(...)`. + /// + /// Used to turn a synchronous exception from the attach/promote step of + /// `execute()` into the same asynchronous failure shape every other + /// dispatch error takes, instead of letting it escape `execute()` as a + /// thrown exception. + /// @tparam R Result type of the action that failed to attach/promote. + /// @param exc Exception to deliver through `.onError(...)`. + /// @return A `Completion` already resolved with @p exc. + template + ::morph::async::Completion failedCompletion(std::exception_ptr exc) { + auto state = std::make_shared<::morph::async::detail::CompletionState>(); + ::morph::async::Completion comp{state, _guiExec}; + state->setException(std::move(exc)); + return comp; + } + Bridge& _bridge; std::weak_ptr _bridgeAlive; // expires when _bridge is destroyed ::morph::exec::IExecutor* _guiExec; diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp index a309c400..69b96017 100644 --- a/tests/test_shared_instances.cpp +++ b/tests/test_shared_instances.cpp @@ -868,3 +868,27 @@ TEST_CASE("an empty primary on the remote backend degrades to a private instance REQUIRE(rebound.v != 0U); REQUIRE(backend.listInstances("SHI_CounterModel").empty()); } + +TEST_CASE("execute() reports an attach failure through onError instead of throwing", "[shared-instances]") { + morph::testing::InlineExecutor exec; + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool); + server->setLimitPolicy({.maxLiveModels = 1}); + + Bridge bridge{std::make_unique(*server)}; + + // Fills the server's one slot with an unrelated private instance. + BridgeHandler filler{bridge, &exec}; + settle(filler.execute(ShiAddTo{.id = 1, .amount = 1})); + + // A payload-keyed action on a fresh shared handler must attach a *new* + // instance for key 2, and the server is already full: the attach call + // the framework makes internally throws. That must not escape execute() + // as a synchronous exception -- it must surface through .onError(), + // exactly like every other dispatch failure. + BridgeHandler handler{bridge, &exec}; + bool failed = false; + REQUIRE_NOTHROW( + handler.execute(ShiAddTo{.id = 2, .amount = 1}).onError([&](const std::exception_ptr&) { failed = true; })); + REQUIRE(failed); +} From 19a2c7012e6e38521b517d23cbd69f8b0ecc5c3e Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 13:23:32 +0300 Subject: [PATCH 31/42] fix(core): make attachModel/the server's attach re-point atomic (acquire before release) --- include/morph/core/backend.hpp | 26 ++++++++++++++++----- include/morph/core/bridge.hpp | 17 ++++++-------- include/morph/core/remote.hpp | 25 +++++++++++++++++---- tests/test_shared_instances.cpp | 40 +++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 20 deletions(-) diff --git a/include/morph/core/backend.hpp b/include/morph/core/backend.hpp index 353b3907..09a4a13c 100644 --- a/include/morph/core/backend.hpp +++ b/include/morph/core/backend.hpp @@ -133,11 +133,14 @@ struct IBackend { /// @brief Re-points from @p current to the shared instance holding @p primary. /// - /// The default implementation releases @p current (when non-zero) and then - /// calls `registerModelShared`, which is exactly right for an in-process - /// backend. Backends behind a wire protocol override this with the single - /// `attach` request so a re-pointing client cannot lose its slot to - /// `LimitPolicy::maxLiveModels` between the release and the acquire. + /// The default implementation acquires the replacement via + /// `registerModelShared` first and only then releases @p current (when + /// non-zero), so a same-key re-attach never destroys and recreates the + /// instance it already holds, and a throwing acquire never strands the + /// caller with neither instance. Backends behind a wire protocol override + /// this with the single `attach` request so a re-pointing client cannot + /// lose its slot to `LimitPolicy::maxLiveModels` between the release and + /// the acquire. /// /// @param typeId String type-id of the model. /// @param factory Callable that constructs the `IModelHolder` (local path only). @@ -147,10 +150,21 @@ struct IBackend { virtual ::morph::exec::detail::ModelId attachModel( const std::string& typeId, std::function()> factory, InstanceIdentity identity, ::morph::exec::detail::ModelId current) { + // Acquire the replacement before releasing `current`, not after: a + // same-key re-attach (registerModelShared finds `current` already + // live in the directory and takes a second reference to it) then + // hands back the identical id instead of destroying and recreating + // it, and a throwing acquire never touches `current` at all, so the + // caller's existing instance is never stranded by a failed attach. + // Either way, exactly one reference on `current` needs releasing + // afterward: the genuinely old instance's, if this re-pointed to a + // different key; or the redundant one registerModelShared just took, + // if it did not. + auto next = registerModelShared(typeId, std::move(factory), identity); if (current.v != 0U) { deregisterModel(current); } - return registerModelShared(typeId, std::move(factory), identity); + return next; } /// @brief Enters an already-live instance into the directory under @p primary. diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index 6fce2691..4abb2f8f 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -288,18 +288,15 @@ class Bridge { if (binding->primary == primary && binding->currentId.load() != 0U) { return; } - binding->contextKey = primary; - // The default `attachModel` releases the current instance before - // acquiring the new one. If the acquire then fails -- a transport error, - // a server at `maxLiveModels` -- the binding must not keep pointing at - // the id it just gave up, or the next execute dispatches to a released - // instance and gets a confusing "model not found" instead of the - // documented "handler not bound". Unbind first, publish only on success. auto const previous = ::morph::exec::detail::ModelId{binding->currentId.load()}; - binding->currentId.store(0); - binding->primary.clear(); + // IBackend::attachModel acquires the replacement instance before + // releasing `previous` (see its doc comment), so a throwing acquire + // never touches `previous` -- it stays exactly as live, and the + // binding (left unchanged below) still correctly points at it. Only a + // successful attach updates contextKey/primary/currentId. auto newId = loadBackend()->attachModel(binding->typeId, binding->modelFactory, - {.contextKey = binding->contextKey, .primary = primary}, previous); + {.contextKey = primary, .primary = primary}, previous); + binding->contextKey = primary; binding->primary = std::move(primary); binding->currentId.store(newId.v); } diff --git a/include/morph/core/remote.hpp b/include/morph/core/remote.hpp index 25acbd23..848b795c 100644 --- a/include/morph/core/remote.hpp +++ b/include/morph/core/remote.hpp @@ -712,7 +712,12 @@ class RemoteServer : public std::enable_shared_from_this { /// @param env Decoded request; uses `typeId`, `primary`, `contextKey`, `callId`. /// @param reply Reply sink; always invoked exactly once. /// @param cid Connection scope, or `0` for unscoped. - /// @param releaseCurrent Instance to release first (an `attach` re-point), or `ModelId{0}`. + /// @param releaseCurrent Instance to release *after* acquiring the target + /// (an `attach` re-point), or `ModelId{0}`. Acquire + /// runs first so a same-key re-attach lands on the + /// same instance instead of destroying and + /// recreating it, and so a failing acquire never + /// touches it. void acquireSharedInstance(const ::morph::wire::Envelope& env, const std::function& reply, ConnectionId cid, ::morph::exec::detail::ModelId releaseCurrent) { LimitPolicy limits; @@ -723,13 +728,25 @@ class RemoteServer : public std::enable_shared_from_this { DirectoryKey dirKey{env.typeId, env.primary}; { std::scoped_lock const lock{_regMtx}; - if (releaseCurrent.v != 0U) { - releaseScopedLocked(releaseCurrent, cid); - } if (attachExistingLocked(dirKey, env, reply, cid)) { + // Acquired (or re-confirmed) the target before touching + // `releaseCurrent` -- a same-key re-attach lands on the exact + // same mid attachExistingLocked just incremented, so + // releasing it here cancels out only the redundant reference + // that call just took, never the caller's sole hold on the + // instance it is "re-pointing" to itself. A genuinely + // different-key re-point releases the real old instance, same + // as before. + if (releaseCurrent.v != 0U) { + releaseScopedLocked(releaseCurrent, cid); + } return; } } + if (releaseCurrent.v != 0U) { + std::scoped_lock const lock{_regMtx}; + releaseScopedLocked(releaseCurrent, cid); + } // Directory miss. Construct outside the lock, exactly as the private // register path does, then re-check under the insert lock: a concurrent // request for the same key may have won the race while we built ours. diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp index 69b96017..bee70fa9 100644 --- a/tests/test_shared_instances.cpp +++ b/tests/test_shared_instances.cpp @@ -892,3 +892,43 @@ TEST_CASE("execute() reports an attach failure through onError instead of throwi handler.execute(ShiAddTo{.id = 2, .amount = 1}).onError([&](const std::exception_ptr&) { failed = true; })); REQUIRE(failed); } + +TEST_CASE("IBackend::attachModel's default re-attach to an already-held key does not destroy the instance", + "[shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + morph::backend::LocalBackend backend{pool}; + + auto held = backend.registerModelShared( + "SHI_CounterModel", [] { return morph::model::detail::ModelFactory::create(); }, + {.contextKey = {}, .primary = "9"}); + + // Re-attaching to the exact key already held must hand back the same + // instance, not destroy and recreate it -- exercised directly against the + // backend since Bridge::attachHandler's own primary-unchanged guard would + // otherwise short-circuit before ever reaching attachModel. + auto again = backend.attachModel( + "SHI_CounterModel", [] { return morph::model::detail::ModelFactory::create(); }, + {.contextKey = {}, .primary = "9"}, held); + REQUIRE(again.v == held.v); + REQUIRE(backend.listInstances("SHI_CounterModel") == std::vector{"9"}); +} + +TEST_CASE("the server's attach is a no-op when re-attaching to the key already held", "[shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool); + + auto reg = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeRegisterShared("SHI_CounterModel", "5")))); + REQUIRE(reg.kind == "ok"); + + auto again = morph::wire::decode(server->handleInline( + morph::wire::encode(morph::wire::makeAttach("SHI_CounterModel", "5", reg.modelId)))); + REQUIRE(again.kind == "ok"); + REQUIRE(again.modelId == reg.modelId); + + auto listed = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeInstances("SHI_CounterModel")))); + std::vector keys; + REQUIRE_FALSE(glz::read_json(keys, listed.body)); + REQUIRE(keys == std::vector{"5"}); +} From 2cdd1b7ea2dbf6deec4bd3b4e2c091f7ae541288 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 13:36:22 +0300 Subject: [PATCH 32/42] fix(bridge): give shared-handler attach/register/assign a dedicated mutex --- docs/spec/core/bridge.md | 12 +++-- include/morph/core/bridge.hpp | 38 +++++++++++--- tests/test_shared_instances.cpp | 93 +++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 10 deletions(-) diff --git a/docs/spec/core/bridge.md b/docs/spec/core/bridge.md index f2f75810..0850a25f 100644 --- a/docs/spec/core/bridge.md +++ b/docs/spec/core/bridge.md @@ -407,8 +407,13 @@ existing subscriber. ## Thread safety `Bridge` is fully thread-safe (see the `Bridge` section: separate -`_backendMtx`, `_mtx`, and `_sessionMtx`, with `executeVia` taking its backend -snapshot under the short, dedicated `_backendMtx` rather than `_mtx`). +`_backendMtx`, `_mtx`, `_sessionMtx`, and `_attachMtx`, with `executeVia` +taking its backend snapshot under the short, dedicated `_backendMtx` rather +than `_mtx`, and a shared handler's `attachHandler`/`ensureBound`/ +`assignHandlerPrimary` running under `_attachMtx` rather than `_mtx`, so a +slow remote round-trip on one handler's attach never blocks another +handler's construction, destruction, or a `switchBackend()` call on the same +`Bridge`). `subscribe`/`unsubscribe` mutate the bridge's subscription registry under `_subMtx`. Callbacks never run under that mutex: `publishResult` snapshots the @@ -465,7 +470,7 @@ make teardown order-independent.) | dtor | `~Bridge()` | Clears the active backend's reconnect handler, then cancels all pending completions with `BridgeDestroyedError`. | | `registerHandler` | `shared_ptr registerHandler()` | Default factory. | | `registerHandler(binding)` | `void registerHandler(const shared_ptr&)` | Pre-built binding. | -| `switchBackend` | `void switchBackend(unique_ptr)` | Atomic: stages all re-registrations on the new backend, commits (publishes new ids + swaps) only if all succeed, else rolls back and rethrows leaving old backend + `currentId`s intact. Cancels old backend's pending ops with `BackendChangedError`. | +| `switchBackend` | `void switchBackend(unique_ptr)` | Atomic: stages all re-registrations on the new backend, commits (publishes new ids + swaps) only if all succeed, else rolls back and rethrows leaving old backend + `currentId`s intact. Cancels old backend's pending ops with `BackendChangedError`. Holds both `_mtx` and `_attachMtx` for its duration. | | `deregisterHandler` | `void deregisterHandler(const shared_ptr&)` | Deregisters from active backend (if bound), resets `currentId` to 0, removes from tracking. | | `executeVia` | `Completion executeVia(const shared_ptr&, Action, IExecutor*)` | Lock-free dispatch. Attaches default session. On `LocalBackend`, rejects an action whose `ActionValidator::ready` returns `false` with `morph::model::ValidationError` via `onError`, before `Model::execute` runs. Records journal for loggable actions. Value-forwarding into the typed `Completion` is `try`/`catch`-guarded — a throwing result move/copy resolves the completion via `onError` instead of hanging or terminating. The bridge-touching side effects (`onResult`, `hasSubscribers()`/`publishResult`) are gated on the `_liveness` token, checked before either runs, so a completion resolving after `~Bridge()` skips them instead of touching the dangling `Bridge`. | | `setDefaultSession` | `void setDefaultSession(session::Context)` | Installs default session context. | @@ -505,6 +510,7 @@ make teardown order-independent.) | Teardown order | **`shared_ptr _liveness` + per-handler `weak_ptr`** | Makes bridge-vs-handler destruction order-independent: a handler outliving its bridge skips deregistration instead of dereferencing a dangling `Bridge&`. Normal `execute`/`subscribe` still require the bridge to outlive its handlers. | | Backend pointer | **Short snapshot under the dedicated `_backendMtx`** | `executeVia()` reads the backend through a `loadBackend()` helper that copies the `shared_ptr` under `_backendMtx` (never `_mtx`), so it never blocks on `switchBackend()`'s `_mtx`. | | Session storage | **Separate `_sessionMtx` from `_mtx`** | Session access is a hot path (every `executeVia` reads it). A separate mutex avoids contention with handler registration/switchBackend. | +| Attach-path locking | **Separate `_attachMtx` from `_mtx`** | `attachHandler`/`ensureBound`/`assignHandlerPrimary` can block on a full network round-trip for a remote backend. A dedicated mutex means that round-trip never blocks unrelated `registerHandler`/`deregisterHandler`/`switchBackend` calls on the same `Bridge`, closing a deadlock hazard if the thread expected to deliver the pending reply itself needs `_mtx`. `HandlerBinding::primary`/`contextKey` are mutated only under `_attachMtx`; `switchBackend()` and the reconnect handler, which also touch them, take both mutexes together. | | Reconnect handler | **Liveness guard + weak‑backend guard + stale check; cleared in `~Bridge`** | The lambda captures a `weak_ptr` to `_liveness` and a `weak_ptr`. On invocation it first locks the liveness token — if the `Bridge` is gone it returns without touching `this` (no use-after-free). It then checks `pinned == loadBackend()` — if a switch occurred since the handler was installed, the reconnect is ignored. `~Bridge` and `switchBackend` also clear the outgoing backend's handler via `setReconnectHandler(nullptr)`; the liveness guard covers a reconnect already in flight when teardown races it. | | Subscription keying | **On the result type, and against the binding rather than an instance id** | A subscriber is a renderer: it cares about the state it draws, not about which of several actions produced it, so a new action yielding the same type never breaks it. Storing against the binding makes a subscription follow a re-pointed handler, which is what "tell me about the account I am looking at" requires. | | Action readiness | **`ActionValidator::ready(snapshot)`** | Framework-agnostic validation — each action struct defines its own required-field semantics. The bridge never interprets action fields. | diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index 4abb2f8f..1a93ea47 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -152,7 +152,8 @@ struct HandlerBinding { /// /// Empty until a keyed action or an explicit `attach` supplies one. Only /// meaningful when `shared` is set; a private binding never consults it. - /// Mutated only under `Bridge::_mtx`. + /// Mutated only under `Bridge::_attachMtx` (or, during `switchBackend()` + /// and the reconnect handler, both `_attachMtx` and `_mtx` together). std::string primary; /// @brief Whether this binding participates in the shared instance directory. @@ -284,7 +285,7 @@ class Bridge { /// @param primary Canonical string encoding of the primary key to attach to. template void attachHandler(const std::shared_ptr& binding, std::string primary) { - std::scoped_lock const lock{_mtx}; + std::scoped_lock const lock{_attachMtx}; if (binding->primary == primary && binding->currentId.load() != 0U) { return; } @@ -309,7 +310,7 @@ class Bridge { /// `assignHandlerPrimary` promotes it in place once the key is known. /// @param binding Shared binding to bind. void ensureBound(const std::shared_ptr& binding) { - std::scoped_lock const lock{_mtx}; + std::scoped_lock const lock{_attachMtx}; if (binding->currentId.load() != 0U) { return; } @@ -328,7 +329,7 @@ class Bridge { /// @param primary Canonical string encoding of the key to file it under. template void assignHandlerPrimary(const std::shared_ptr& binding, std::string primary) { - std::scoped_lock const lock{_mtx}; + std::scoped_lock const lock{_attachMtx}; uint64_t const raw = binding->currentId.load(); if (raw == 0U || primary.empty()) { return; @@ -342,7 +343,7 @@ class Bridge { /// @param binding Binding to inspect. /// @return Canonical key string, or an empty string when unattached. [[nodiscard]] std::string bindingPrimary(const std::shared_ptr& binding) { - std::scoped_lock const lock{_mtx}; + std::scoped_lock const lock{_attachMtx}; return binding->primary; } @@ -497,7 +498,12 @@ class Bridge { auto newShared = std::shared_ptr<::morph::backend::detail::IBackend>(std::move(newBackend)); std::shared_ptr<::morph::backend::detail::IBackend> previous; { - std::scoped_lock const lock{_mtx}; + // Both mutexes: this phase reads/writes every live binding's + // `primary`/`contextKey` (via `_attachMtx`'s ownership of those + // fields) as well as `_handlers` itself (via `_mtx`), and must not + // race a concurrent attachHandler()/ensureBound()/ + // assignHandlerPrimary() call on any one of them. + std::scoped_lock const lock{_mtx, _attachMtx}; // Phase 1 — register every live binding on the new backend WITHOUT // mutating any `currentId` yet, staging (binding, newId) pairs. If a @@ -793,7 +799,10 @@ class Bridge { return; // The Bridge is gone; do not touch `this`. } auto pinned = weakBackend.lock(); - std::scoped_lock const lock{_mtx}; + // Both mutexes: reads `_handlers` (guarded by `_mtx`) and each + // binding's `contextKey` (guarded by `_attachMtx`), same + // reasoning as switchBackend() above. + std::scoped_lock const lock{_mtx, _attachMtx}; if (!pinned || pinned != loadBackend()) { return; // We've moved on to a different backend; ignore. } @@ -813,6 +822,21 @@ class Bridge { std::shared_ptr<::morph::backend::detail::IBackend> _backend; std::mutex _mtx; std::vector> _handlers; + // Guards the shared-handler attach/register/assign path (ensureBound, + // attachHandler, assignHandlerPrimary) separately from `_mtx`, which + // guards `_handlers` membership and the active-backend swap. + // attachModel/registerModelShared/assignPrimary can block on a full + // network round-trip for a remote backend; if that ran under `_mtx`, an + // unrelated handler's construction or destruction, or a switchBackend() + // call, on the *same* Bridge would block for the same round-trip, and a + // reply-delivering thread that itself needed `_mtx` could deadlock + // against it. `HandlerBinding::primary`/`contextKey` are therefore + // mutated (and must be read) only under `_attachMtx` — never under `_mtx` + // alone. `switchBackend()` and the reconnect handler, which also touch + // them alongside `_handlers`, take both mutexes together via + // `std::scoped_lock{_mtx, _attachMtx}` (deadlock-safe regardless of + // acquisition order, by `std::scoped_lock`'s own guarantee). + std::mutex _attachMtx; mutable std::mutex _sessionMtx; ::morph::session::Context _defaultSession; // Instance subscriptions. Held against the binding rather than a fixed diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp index bee70fa9..e220efd8 100644 --- a/tests/test_shared_instances.cpp +++ b/tests/test_shared_instances.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include "test_support.hpp" @@ -932,3 +933,95 @@ TEST_CASE("the server's attach is a no-op when re-attaching to the key already h REQUIRE_FALSE(glz::read_json(keys, listed.body)); REQUIRE(keys == std::vector{"5"}); } + +namespace { + +/// An IBackend whose shared-register/attach blocks until told to proceed, so +/// a test can hold Bridge's attach path "in flight" and prove unrelated +/// handler registration on the same Bridge does not wait behind it. +struct SlowAttachBackend : morph::backend::detail::IBackend { + std::atomic attachStarted{false}; + std::atomic proceed{false}; + std::atomic nextId{0}; + + morph::exec::detail::ModelId registerModel( + const std::string&, std::function()> factory) override { + auto holder = factory(); + (void)holder; + return morph::exec::detail::ModelId{++nextId}; + } + morph::exec::detail::ModelId registerModelShared(const std::string& typeId, + std::function()> factory, + morph::backend::detail::InstanceIdentity identity) override { + if (identity.primary.empty()) { + return registerModel(typeId, std::move(factory)); + } + attachStarted.store(true); + while (!proceed.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + auto holder = factory(); + (void)holder; + return morph::exec::detail::ModelId{++nextId}; + } + void deregisterModel(morph::exec::detail::ModelId) override {} + morph::async::Completion> execute(morph::exec::detail::ModelId, + morph::backend::detail::ActionCall, + morph::exec::IExecutor* cbExec) override { + auto state = std::make_shared>>(); + morph::async::Completion> comp{state, cbExec}; + state->setException(std::make_exception_ptr(std::runtime_error("not implemented"))); + return comp; + } + void notifyBackendChanged() override {} + void cancelPending(const std::exception_ptr&) override {} +}; + +} // namespace + +TEST_CASE("Bridge: an in-flight shared attach does not block unrelated handler registration", "[shared-instances]") { + morph::testing::InlineExecutor exec; + auto backend = std::make_unique(); + auto* backendPtr = backend.get(); + Bridge bridge{std::move(backend)}; + + BridgeHandler attacher{bridge, &exec}; + std::thread attachThread([&] { attacher.attach(1); }); + std::thread unrelatedThread; + std::atomic unrelatedDone{false}; + + // Guarantees both threads are joined -- even if a REQUIRE below throws -- + // so a failing assertion never leaves a joinable std::thread dangling + // (which would std::terminate) or the backend permanently blocked. + // Unblocking the backend before joining means this cleanup itself cannot + // hang: once `proceed` is set, attachThread's backend call returns and + // releases whatever lock it was holding, so a still-pending + // unrelatedThread (blocked acquiring that same lock, pre-fix) is freed to + // finish too. + auto joinAll = [&] { + backendPtr->proceed.store(true); + if (attachThread.joinable()) { + attachThread.join(); + } + if (unrelatedThread.joinable()) { + unrelatedThread.join(); + } + }; + try { + REQUIRE(morph::testing::waitUntil([&] { return backendPtr->attachStarted.load(); })); + + // While the attach above is still blocked inside the backend, registering + // an unrelated handler on the same Bridge must not block behind it: a + // dedicated attach mutex means registerHandler() no longer contends for + // the same lock as a slow shared attach. + unrelatedThread = std::thread([&] { + BridgeHandler unrelated{bridge, &exec}; + unrelatedDone.store(true); + }); + REQUIRE(morph::testing::waitUntil([&] { return unrelatedDone.load(); })); + } catch (...) { + joinAll(); + throw; + } + joinAll(); +} From 2adebd42af21f7b3fd6aa9b26f5fc63079115f40 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 13:47:41 +0300 Subject: [PATCH 33/42] fix(core): forbid assignPrimary from re-keying an already-keyed shared instance LocalBackend::assignPrimary and RemoteServer::applyAssignLocked promote only a still-anonymous instance now: one already filed under a different real key is left exactly where it is instead of being silently re-filed, which would otherwise strand any other client still attached under the old key. This makes the code actually enforce the spec's "instances never change key" invariant. Also gate Bridge::assignHandlerPrimary's local bookkeeping the same way: it must not cache a primary the backend refused to file the instance under, which is exactly what the rewritten "a result-sourced key is not promoted when the handler already holds a real key" test caught. --- docs/spec/core/shared_instances.md | 18 +++++++----- include/morph/core/backend.hpp | 23 +++++++++++----- include/morph/core/bridge.hpp | 9 ++++-- include/morph/core/remote.hpp | 14 ++++++---- tests/test_shared_instances.cpp | 44 ++++++++++++++++++------------ 5 files changed, 69 insertions(+), 39 deletions(-) diff --git a/docs/spec/core/shared_instances.md b/docs/spec/core/shared_instances.md index bd6b9655..fc082511 100644 --- a/docs/spec/core/shared_instances.md +++ b/docs/spec/core/shared_instances.md @@ -251,13 +251,17 @@ established means an older peer ignores what it does not understand. `ModelId`. Semantically a `deregister` + `register` pair, made atomic so a re-pointing handler cannot lose its slot to `LimitPolicy::maxLiveModels` in between. -- **A new `assign` request.** Files an already-live `modelId` under a primary - key, in place. This is what makes a result-sourced key work without losing - state: an action that creates its own entity runs on a not-yet-keyed - instance, and only the reply carries the generated key, so the instance the - action ran on is promoted rather than abandoned for a fresh one. The existing - holder of a key always wins — promoting onto a taken key is a silent no-op, - never a displacement. +- **A new `assign` request.** Files an already-live, still-anonymous `modelId` + under a primary key, in place. This is what makes a result-sourced key work + without losing state: an action that creates its own entity runs on a + not-yet-keyed instance, and only the reply carries the generated key, so the + instance the action ran on is promoted rather than abandoned for a fresh + one. Promotion only ever applies to a still-anonymous instance: the + existing holder of a key always wins (promoting onto a taken key is a + silent no-op, never a displacement), and an instance that already holds a + *different* real key is left exactly where it is (also a silent no-op) — + instances never change key, so `assign` never reaches for one still in use + elsewhere. - **A new `instances` request.** Takes a model type id, replies with the live primary keys for it. Subject to `authorize` like any other request; see below. diff --git a/include/morph/core/backend.hpp b/include/morph/core/backend.hpp index 09a4a13c..c35286b3 100644 --- a/include/morph/core/backend.hpp +++ b/include/morph/core/backend.hpp @@ -176,10 +176,13 @@ struct IBackend { /// strand everything the create just did, so instead the instance the action /// ran on is given the generated key in place. /// - /// A no-op when @p primary is empty, when @p mid is not live, or when another - /// instance already holds that key — the existing holder always wins, so a + /// A no-op when @p primary is empty, when @p mid is not live, when another + /// instance already holds that key, or when @p mid itself already holds a + /// *different* real key. The existing holder of a key always wins (a /// promotion can never silently displace a directory entry other handlers - /// are already attached to. + /// are attached to), and an already-keyed instance never changes key (a + /// promotion can never silently move one out from under handlers already + /// attached to it) — only a still-anonymous instance can ever be promoted. /// /// @param mid Live instance to promote. /// @param typeId Model type id — the directory's first key component. @@ -354,7 +357,8 @@ class LocalBackend : public detail::IBackend { return mid; } - /// @brief Enters an already-live instance into the directory under @p primary. Thread-safe. + /// @brief Enters an already-live, still-anonymous instance into the + /// directory under @p primary. Thread-safe. /// @param mid Live instance to promote. /// @param typeId Model type id — the directory's first key component. /// @param primary Canonical string encoding of the key to file it under. @@ -371,9 +375,14 @@ class LocalBackend : public detail::IBackend { if (_directory.contains(dirKey)) { return; } - if (auto prevIter = _sharedKeyOf.find(mid); prevIter != _sharedKeyOf.end()) { - _directory.erase(prevIter->second); - _sharedKeyOf.erase(prevIter); + // Only a truly anonymous instance (no existing directory entry) can + // be promoted. An instance already filed under a *different* real key + // must not be silently re-filed onto this one -- instances never + // change key; re-pointing the handler is the supported way to move to + // a different entity, and it leaves the old key, and every other + // client still attached under it, untouched. + if (_sharedKeyOf.contains(mid)) { + return; } _directory.emplace(dirKey, mid); _sharedKeyOf.emplace(mid, std::move(dirKey)); diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index 1a93ea47..b9c2cec3 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -323,7 +323,12 @@ class Bridge { /// /// The instance keeps everything the creating action just did — nothing is /// re-created and nothing is stranded. A no-op if another instance already - /// holds that key; the existing holder always wins. + /// holds that key (the existing holder always wins) or if @p binding + /// already holds a *different* real key — `IBackend::assignPrimary` only + /// ever promotes a still-anonymous instance, so the locally cached primary + /// must not race ahead of it: updating it here regardless would make + /// `primary()` report a key the backend never actually filed the instance + /// under. /// @tparam Model Concrete model type. /// @param binding Shared binding whose instance is being promoted. /// @param primary Canonical string encoding of the key to file it under. @@ -331,7 +336,7 @@ class Bridge { void assignHandlerPrimary(const std::shared_ptr& binding, std::string primary) { std::scoped_lock const lock{_attachMtx}; uint64_t const raw = binding->currentId.load(); - if (raw == 0U || primary.empty()) { + if (raw == 0U || primary.empty() || !binding->primary.empty()) { return; } loadBackend()->assignPrimary(::morph::exec::detail::ModelId{raw}, binding->typeId, primary); diff --git a/include/morph/core/remote.hpp b/include/morph/core/remote.hpp index 848b795c..4a8bd32a 100644 --- a/include/morph/core/remote.hpp +++ b/include/morph/core/remote.hpp @@ -775,11 +775,14 @@ class RemoteServer : public std::enable_shared_from_this { reply(::morph::wire::encode(::morph::wire::makeOk(env.callId, {}, fresh.v))); } - /// @brief Files a live instance under a primary key, in place. + /// @brief Files a live, still-anonymous instance under a primary key, in place. /// /// The existing holder of a key always wins: promoting onto a key another - /// instance already holds is a silent no-op rather than a displacement, so a - /// promotion can never steal an entry handlers are already attached to. + /// instance already holds is a silent no-op rather than a displacement. + /// Symmetrically, an instance that already holds a *different* real key is + /// left exactly where it is — also a silent no-op — since instances never + /// change key (docs/spec/core/shared_instances.md); only a still-anonymous + /// `mid` (no existing `_sharedKeyOf` entry) can ever be promoted. /// @param env Decoded request; uses `typeId`, `primary`, `modelId`. void applyAssignLocked(const ::morph::wire::Envelope& env) { ::morph::exec::detail::ModelId const mid{env.modelId}; @@ -790,9 +793,8 @@ class RemoteServer : public std::enable_shared_from_this { if (_directory.contains(dirKey)) { return; } - if (auto prevIter = _sharedKeyOf.find(mid); prevIter != _sharedKeyOf.end()) { - _directory.erase(prevIter->second); - _sharedKeyOf.erase(prevIter); + if (_sharedKeyOf.contains(mid)) { + return; } _directory.emplace(dirKey, mid); _sharedKeyOf.emplace(mid, std::move(dirKey)); diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp index e220efd8..64e97ea2 100644 --- a/tests/test_shared_instances.cpp +++ b/tests/test_shared_instances.cpp @@ -576,21 +576,26 @@ TEST_CASE("IBackend's sharing defaults degrade to private instances", "[shared-i REQUIRE_NOTHROW(backend.assignPrimary(first, "SHI_CounterModel", "1")); } -TEST_CASE("assignPrimary re-files an instance and ignores unusable input", "[shared-instances]") { +TEST_CASE("assignPrimary promotes an anonymous instance and ignores unusable input", "[shared-instances]") { morph::exec::ThreadPoolExecutor pool{2}; morph::backend::LocalBackend backend{pool}; + // An anonymous instance -- no primary yet -- is the only kind + // assignPrimary may ever promote. auto mid = backend.registerModelShared("SHI_CounterModel", [] { return morph::model::detail::ModelFactory::create(); }, - {.contextKey = {}, .primary = "old"}); - REQUIRE(backend.listInstances("SHI_CounterModel") == std::vector{"old"}); + {.contextKey = {}, .primary = {}}); + REQUIRE(backend.listInstances("SHI_CounterModel").empty()); - // Re-filing drops the previous entry rather than leaving the instance - // reachable under two keys, which would break the one-key-one-instance rule. backend.assignPrimary(mid, "SHI_CounterModel", "new"); REQUIRE(backend.listInstances("SHI_CounterModel") == std::vector{"new"}); - // Neither of these names something actionable, so both are no-ops. + // Already keyed, so a second promotion is a no-op: instances never change + // key once they have a real one. + backend.assignPrimary(mid, "SHI_CounterModel", "other"); + REQUIRE(backend.listInstances("SHI_CounterModel") == std::vector{"new"}); + + // Neither of these names something actionable, so both are also no-ops. backend.assignPrimary(mid, "SHI_CounterModel", ""); backend.assignPrimary(morph::exec::detail::ModelId{99999}, "SHI_CounterModel", "ghost"); REQUIRE(backend.listInstances("SHI_CounterModel") == std::vector{"new"}); @@ -673,21 +678,25 @@ TEST_CASE("assign is refused by an authorizer that denies", "[shared-instances]" REQUIRE(assign.message == "unauthorized"); } -TEST_CASE("a result-sourced key on an already-attached handler promotes in place", "[shared-instances]") { +TEST_CASE("a result-sourced key is not promoted when the handler already holds a real key", "[shared-instances]") { morph::testing::InlineExecutor exec; Bridge bridge{makeLocal(exec)}; BridgeHandler handler{bridge, &exec}; - handler.attach(600); // already bound… + handler.attach(600); // already bound to a real key… settle(handler.execute(ShiAddTo{.id = 600, .amount = 3})); - // …so the create does not need an anonymous instance conjured for it; it - // runs on the one already held, and that instance is re-filed under the - // generated key with its state intact. + // …so a creating action's result-sourced key must not re-file this + // instance out from under 600: instances never change key, and another + // client may still be attached under it. auto created = settle(handler.execute(ShiCreateAs{.wantId = 601, .initial = 9})); REQUIRE(created.id == 601); - REQUIRE(handler.primary().value_or(-1) == 601); - REQUIRE(settle(handler.execute(ShiPeek{})).value == 9); + REQUIRE(handler.primary().value_or(-1) == 600); + + // Key 601 was never filed: a fresh attach to it starts from zero. + BridgeHandler other{bridge, &exec}; + other.attach(601); + REQUIRE(settle(other.execute(ShiPeek{})).value == 0); } TEST_CASE("a subscriber with no executor is called inline", "[shared-instances]") { @@ -783,7 +792,7 @@ TEST_CASE("a change-aware model is tracked when registered shared", "[shared-ins REQUIRE(settle(handler.execute(ShiAwareRead{.id = 900})).value == 1); } -TEST_CASE("the server re-files an instance onto a new key over the wire", "[shared-instances]") { +TEST_CASE("the server refuses to re-file an already-keyed instance onto a different key", "[shared-instances]") { morph::exec::ThreadPoolExecutor pool{2}; auto server = std::make_shared(pool); @@ -791,15 +800,16 @@ TEST_CASE("the server re-files an instance onto a new key over the wire", "[shar server->handleInline(morph::wire::encode(morph::wire::makeRegisterShared("SHI_CounterModel", "old")))); REQUIRE(reg.kind == "ok"); - // Assigning a *new* key to an already-filed instance drops the old entry — - // leaving it reachable under two keys would break one-key-one-instance. + // The instance already has a real key ("old"); assign must leave it there + // rather than silently moving it, which would strand any other client + // still attached under "old". server->handleInline(morph::wire::encode(morph::wire::makeAssign("SHI_CounterModel", "new", reg.modelId))); auto listed = morph::wire::decode( server->handleInline(morph::wire::encode(morph::wire::makeInstances("SHI_CounterModel")))); std::vector keys; REQUIRE_FALSE(glz::read_json(keys, listed.body)); - REQUIRE(keys == std::vector{"new"}); + REQUIRE(keys == std::vector{"old"}); } TEST_CASE("an attach that creates the instance carries contextKey to a configured LogProvider", "[shared-instances]") { From 393b0675172a3c0eb13403b042e5efa45319cb15 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 14:00:57 +0300 Subject: [PATCH 34/42] feat(core): release a freshly created shared instance whose first action fails --- include/morph/core/backend.hpp | 54 +++++++++++++++++++++++++-- include/morph/core/remote.hpp | 40 ++++++++++++++++++++ tests/test_shared_instances.cpp | 65 +++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 4 deletions(-) diff --git a/include/morph/core/backend.hpp b/include/morph/core/backend.hpp index c35286b3..39892f82 100644 --- a/include/morph/core/backend.hpp +++ b/include/morph/core/backend.hpp @@ -342,8 +342,21 @@ class LocalBackend : public detail::IBackend { DirectoryKey dirKey{typeId, std::string{identity.primary}}; std::scoped_lock const lock{_regMtx}; if (auto found = _directory.find(dirKey); found != _directory.end()) { - _attachCount[found->second] += 1; - return found->second; + auto const foundMid = found->second; + auto flagsIter = _hydrationFlags.find(foundMid); + if (flagsIter != _hydrationFlags.end() && flagsIter->second->poisoned.load()) { + // Its first action already failed; it must not be handed to a + // new attacher. Evict it and fall through to the fresh- + // instance path below, exactly as if this had been a + // directory miss. Its own attachCount reference is untouched, + // so whoever created it still tears it down normally when + // they release it. + _directory.erase(found); + _sharedKeyOf.erase(foundMid); + } else { + _attachCount[foundMid] += 1; + return foundMid; + } } ::morph::exec::detail::ModelId const mid{_nextId.fetch_add(1) + 1}; auto holder = factory(); @@ -354,6 +367,7 @@ class LocalBackend : public detail::IBackend { _directory.emplace(dirKey, mid); _sharedKeyOf.emplace(mid, std::move(dirKey)); _attachCount[mid] = 1; + _hydrationFlags[mid] = std::make_shared(); return mid; } @@ -426,6 +440,7 @@ class LocalBackend : public detail::IBackend { } _models.erase(mid); _changeAware.erase(mid); + _hydrationFlags.erase(mid); } /// @brief Schedules `onBackendChanged()` on each change-aware model's strand. Thread-safe. @@ -488,12 +503,16 @@ class LocalBackend : public detail::IBackend { ::morph::async::Completion> comp{compState, cbExec}; std::shared_ptr<::morph::model::detail::IModelHolder> holder; + std::shared_ptr hydration; { std::scoped_lock const lock{_regMtx}; auto iter = _models.find(mid); if (iter != _models.end()) { holder = iter->second; } + if (auto flagsIter = _hydrationFlags.find(mid); flagsIter != _hydrationFlags.end()) { + hydration = flagsIter->second; + } } if (!holder) { compState->setException( @@ -509,14 +528,15 @@ class LocalBackend : public detail::IBackend { // Constraints note on `~StrandExecutor`'s member-destruction-order // subtlety. A shared_ptr copy has its own lifetime, independent of // LocalBackend's, so it stays valid even if the backend is torn down - // while this task is still queued or running. + // while this task is still queued or running. `hydration` follows the + // same rule and may be null (a private instance has no entry). auto inFlightCounter = _inFlight; auto const inFlightAfterInc = inFlightCounter->fetch_add(1, std::memory_order_relaxed) + 1; ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeInFlight, static_cast(inFlightAfterInc)); _strand.post(mid, [localOp = std::move(localOp), holder = std::move(holder), compState, session = std::move(session), modelTypeId = std::move(modelTypeId), - actionTypeId = std::move(actionTypeId), inFlightCounter]() mutable { + actionTypeId = std::move(actionTypeId), inFlightCounter, hydration]() mutable { auto const start = std::chrono::steady_clock::now(); auto const spanId = ::morph::observe::detail::beginSpan(session.requestId, modelTypeId, actionTypeId); bool ok = false; @@ -547,6 +567,14 @@ class LocalBackend : public detail::IBackend { auto const inFlightAfterDec = inFlightCounter->fetch_sub(1, std::memory_order_relaxed) - 1; ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeInFlight, static_cast(inFlightAfterDec)); + // Only a freshly created shared instance carries a HydrationFlags + // token (a private instance, or one that was already live at + // attach time, has none). Its very first action's outcome decides + // whether it gets poisoned; a later action's failure is an + // ordinary error, not a hydration failure. + if (hydration && hydration->firstActionPending.exchange(false) && !ok) { + hydration->poisoned.store(true); + } // Resolve last: the Completion is still settled exactly once, only // its position relative to the now-recorded instrumentation moved. if (ok) { @@ -598,10 +626,28 @@ class LocalBackend : public detail::IBackend { // maintain. Only instances registered with a non-empty primary appear here; // a private instance has no entry in any of the three, which is exactly what // makes deregisterModel's decrement path a no-op for it. + // Tracks, per freshly created shared instance, whether its very first + // action has settled yet and — if it has — whether that first action + // failed. Consulted lazily by registerModelShared's directory-hit branch, + // which evicts a "poisoned" instance (its first action failed, so per + // docs/spec/core/shared_instances.md's Failure modes it must not be left + // half-hydrated in the directory) and falls through to creating a fresh + // one, instead of handing the broken instance to a new attacher. Owned + // via shared_ptr and captured that way — never via raw `this` — into + // execute()'s strand task, which may still be running after LocalBackend + // itself is destroyed (see execute()'s existing capture-by-shared_ptr + // rationale). + struct HydrationFlags { + std::atomic firstActionPending{true}; + std::atomic poisoned{false}; + }; + using DirectoryKey = std::pair; std::unordered_map _directory; std::unordered_map<::morph::exec::detail::ModelId, DirectoryKey, ::morph::exec::detail::ModelIdHash> _sharedKeyOf; std::unordered_map<::morph::exec::detail::ModelId, std::size_t, ::morph::exec::detail::ModelIdHash> _attachCount; + std::unordered_map<::morph::exec::detail::ModelId, std::shared_ptr, ::morph::exec::detail::ModelIdHash> + _hydrationFlags; std::atomic _nextId{0}; std::mutex _pendingMtx; std::vector>>> _pending; diff --git a/include/morph/core/remote.hpp b/include/morph/core/remote.hpp index 4a8bd32a..ebe5e13d 100644 --- a/include/morph/core/remote.hpp +++ b/include/morph/core/remote.hpp @@ -612,6 +612,8 @@ class RemoteServer : public std::enable_shared_from_this { } _models.erase(mid); _owners.erase(mid); + _firstActionPending.erase(mid); + _poisoned.erase(mid); return true; } @@ -689,6 +691,16 @@ class RemoteServer : public std::enable_shared_from_this { return false; } auto const mid = found->second; + if (_poisoned.contains(mid)) { + // This instance's first action already failed; it must not be + // handed to a new attacher. Evict it from the directory -- its + // own eventual release still tears it down normally -- and report + // a miss so the caller falls through to creating a fresh + // instance. + _directory.erase(found); + _sharedKeyOf.erase(mid); + return false; + } _attachCount[mid] += 1; if (!noteScopeAttachLocked(mid, cid)) { releaseInstanceLocked(mid); @@ -771,6 +783,7 @@ class RemoteServer : public std::enable_shared_from_this { _directory.emplace(dirKey, fresh); _sharedKeyOf.emplace(fresh, std::move(dirKey)); _attachCount[fresh] = 1; + _firstActionPending.insert(fresh); } reply(::morph::wire::encode(::morph::wire::makeOk(env.callId, {}, fresh.v))); } @@ -1227,6 +1240,7 @@ class RemoteServer : public std::enable_shared_from_this { } _strand.post(mid, [self, env = std::move(env), holder = std::move(holder), complete, timeoutHandle]() mutable { + ::morph::exec::detail::ModelId const mid{env.modelId}; auto const start = std::chrono::steady_clock::now(); auto const spanId = ::morph::observe::detail::beginSpan(env.session.requestId, env.modelType, env.actionType); @@ -1261,6 +1275,10 @@ class RemoteServer : public std::enable_shared_from_this { std::array, 2> const tags{ {{"modelType", env.modelType}, {"actionType", env.actionType}}}; ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeLatencyMs, elapsedMs, tags); + { + std::scoped_lock const lock{self->_regMtx}; + self->_firstActionPending.erase(mid); + } complete(::morph::wire::encode(::morph::wire::makeOk(env.callId, std::move(result)))); } catch (const std::exception& exc) { { @@ -1276,6 +1294,13 @@ class RemoteServer : public std::enable_shared_from_this { {{"modelType", env.modelType}, {"actionType", env.actionType}}}; ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeLatencyMs, elapsedMs, tags); ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeErrors, 1.0, tags); + { + std::scoped_lock const lock{self->_regMtx}; + if (auto iter = self->_firstActionPending.find(mid); iter != self->_firstActionPending.end()) { + self->_firstActionPending.erase(iter); + self->_poisoned.insert(mid); + } + } complete(::morph::wire::encode(::morph::wire::makeErr(exc.what(), env.callId))); } }); @@ -1334,6 +1359,21 @@ class RemoteServer : public std::enable_shared_from_this { std::unordered_map _directory; std::unordered_map<::morph::exec::detail::ModelId, DirectoryKey, ::morph::exec::detail::ModelIdHash> _sharedKeyOf; std::unordered_map<::morph::exec::detail::ModelId, std::size_t, ::morph::exec::detail::ModelIdHash> _attachCount; + // First-action hydration tracking for freshly-created shared instances + // (docs/spec/core/shared_instances.md's Failure modes: a failed first + // action on a freshly created shared instance must not be left in the + // directory in a half-hydrated state). `_firstActionPending` holds a mid + // while its very first execute hasn't settled yet; `_poisoned` holds a + // mid whose first action failed. Consulted lazily by + // attachExistingLocked, which evicts and falls through to creating a + // fresh instance instead of handing a poisoned one to a new attacher. + // Guarded by `_regMtx` alongside `_models`/`_directory`. `dispatchExecute`'s + // strand task safely mutates these directly via its own `self = + // shared_from_this()` capture (this class's documented heap-allocation + // contract), unlike LocalBackend's strand task, which must never touch + // raw `this`. + std::unordered_set<::morph::exec::detail::ModelId, ::morph::exec::detail::ModelIdHash> _firstActionPending; + std::unordered_set<::morph::exec::detail::ModelId, ::morph::exec::detail::ModelIdHash> _poisoned; std::atomic _nextId{0}; std::atomic _nextConnectionId{0}; std::atomic _minVersion{::morph::wire::kProtocolVersion}; diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp index 64e97ea2..824931ea 100644 --- a/tests/test_shared_instances.cpp +++ b/tests/test_shared_instances.cpp @@ -172,6 +172,29 @@ BRIDGE_REGISTER_ACTION(AutoLoadModel, AutoPeek, "SHI_AutoPeek") BRIDGE_MODEL_KEY(AutoLoadModel, AutoLoad, &AutoLoad::id); // NOLINTEND(misc-use-internal-linkage) +// NOLINTBEGIN(misc-use-internal-linkage) +/// A model whose first action can be made to fail on demand, to exercise +/// "a failed first action on a freshly created shared instance releases it" +/// (docs/spec/core/shared_instances.md's Failure modes). +struct ShiHydrateFail { + std::int64_t id = 0; +}; +struct ShiHydrateOk { + std::int64_t id = 0; +}; +struct ShiHydrateModel { + ShiCounterState execute(const ShiHydrateFail&) { throw std::runtime_error("hydration failed"); } + ShiCounterState execute(const ShiHydrateOk&) { return {.value = 1}; } +}; + +BRIDGE_REGISTER_MODEL(ShiHydrateModel, "SHI_HydrateModel") +BRIDGE_REGISTER_ACTION(ShiHydrateModel, ShiHydrateFail, "SHI_HydrateFail") +BRIDGE_REGISTER_ACTION(ShiHydrateModel, ShiHydrateOk, "SHI_HydrateOk") + +BRIDGE_MODEL_KEY(ShiHydrateModel, ShiHydrateFail, &ShiHydrateFail::id); +BRIDGE_KEY_FROM(ShiHydrateOk, &ShiHydrateOk::id); +// NOLINTEND(misc-use-internal-linkage) + namespace { using morph::bridge::AllowShared; @@ -1035,3 +1058,45 @@ TEST_CASE("Bridge: an in-flight shared attach does not block unrelated handler r } joinAll(); } + +TEST_CASE("a failed first action releases a freshly created shared instance from the directory", "[shared-instances]") { + morph::testing::InlineExecutor exec; + Bridge bridge{makeLocal(exec)}; + + BridgeHandler first{bridge, &exec}; + bool failed = false; + first.execute(ShiHydrateFail{.id = 1}).onError([&](const std::exception_ptr&) { failed = true; }); + REQUIRE(failed); + + // The broken instance must not be handed to a second attacher: it gets a + // fresh instance instead, on which the same key's normal action succeeds. + BridgeHandler second{bridge, &exec}; + REQUIRE(settle(second.execute(ShiHydrateOk{.id = 1})).value == 1); +} + +TEST_CASE("the server releases a freshly created shared instance whose first action fails", "[shared-instances]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool); + + auto reg = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeRegisterShared("SHI_HydrateModel", "1")))); + REQUIRE(reg.kind == "ok"); + + morph::wire::Envelope failExec; + failExec.kind = "execute"; + failExec.modelId = reg.modelId; + failExec.modelType = "SHI_HydrateModel"; + failExec.actionType = "SHI_HydrateFail"; + failExec.body = R"({"id":1})"; + morph::testing::WaitReply waiter; + server->handle(morph::wire::encode(failExec), std::ref(waiter)); + REQUIRE(waiter.await()); + REQUIRE(waiter.env.kind == "err"); + + // A second shared register for the same key must not reach the poisoned + // instance -- it gets a fresh one. + auto second = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeRegisterShared("SHI_HydrateModel", "1")))); + REQUIRE(second.kind == "ok"); + REQUIRE(second.modelId != reg.modelId); +} From 2814b9906f9106ea4abbed3f267310910a691107 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 14:07:05 +0300 Subject: [PATCH 35/42] test(shared-instances): assert the second attacher lands on a different instance id ShiHydrateModel carries no observable state, so the existing value-based assertion cannot distinguish a fresh instance from the reused, poisoned one. Compare BridgeHandler::binding()->currentId directly, the same way the remote test already compares modelId. --- tests/test_shared_instances.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp index 824931ea..dc10c7e6 100644 --- a/tests/test_shared_instances.cpp +++ b/tests/test_shared_instances.cpp @@ -1072,6 +1072,12 @@ TEST_CASE("a failed first action releases a freshly created shared instance from // fresh instance instead, on which the same key's normal action succeeds. BridgeHandler second{bridge, &exec}; REQUIRE(settle(second.execute(ShiHydrateOk{.id = 1})).value == 1); + // ShiHydrateModel carries no observable state, so the value check above + // cannot by itself tell a fresh instance apart from the reused, poisoned + // one. `first` is still attached (its attachment was never released), so + // the poisoned instance is still alive; confirm `second` really landed on + // a *different* instance, not the same one reused. + REQUIRE(second.binding()->currentId.load() != first.binding()->currentId.load()); } TEST_CASE("the server releases a freshly created shared instance whose first action fails", "[shared-instances]") { From 841bde10b092cf0ec0627b7570e072989b3d38cd Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 14:11:18 +0300 Subject: [PATCH 36/42] docs(shared_instances): document the empty-string primary key limitation --- docs/spec/core/shared_instances.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/spec/core/shared_instances.md b/docs/spec/core/shared_instances.md index fc082511..33362762 100644 --- a/docs/spec/core/shared_instances.md +++ b/docs/spec/core/shared_instances.md @@ -405,6 +405,17 @@ strictly reduces pressure on it. attached explicitly after login. - **One key per instance.** No secondary keys, no alternate indexes, no querying the directory by anything but model type. +- **An empty-string primary key means "no primary".** `primary.empty()` is the + sentinel every layer (`LocalBackend::registerModelShared`/`assignPrimary`, + `Bridge::assignHandlerPrimary`, `RemoteServer`'s directory operations) uses + for "anonymous, therefore unshareable" — there is no separate encoding for + "a real key whose value happens to be the empty string". A model whose + `PrimaryKey` is `std::string` and whose legitimate key value is `""` will + silently get a private, unshared instance instead of an error or real + sharing; two callers both attaching with `primary == ""` never reach the + same instance. Choose a non-empty key encoding (e.g. reserve a sentinel + string, or key on something that is never empty) if this applies to your + model. - **Enumeration is per model type and unfiltered.** No paging, no predicate; a model type with very many live instances returns all of them. - **`instances()` discloses live keys** to any principal `authorize` admits. From 9857490449e9ab4a61f54b62feca86ea0e88601b Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 14:41:37 +0300 Subject: [PATCH 37/42] fix(remote): make acquireSharedInstance's directory-miss re-point atomic releaseCurrent used to be released in a standalone step between the two locked sections of acquireSharedInstance's miss path -- before the maxLiveModels admission check and before the second lock's re-check hit branch (which released nothing at all). A subsequently failing admission check or a throwing construction could therefore strand the caller's handler: its old instance already gone, its new one never created. Move the release into the same locked section as the maxLiveModels check (so a sole holder's release frees exactly the slot the re-point needs instead of losing it to the cap) and add the missing release on the re-check hit branch. Update attachHandler's comment in bridge.hpp to match the now-accurate guarantee: a throwing acquire never touches `previous` except when a connection scope closes concurrently, which is harmless since no further request on it will ever run. Add a maxLiveModels re-point regression test, plus a second test that directly discriminates the fix via a throwing model construction (the first test alone happens to pass under the old ordering too, since the single-threaded case it covers works out arithmetically either way). Co-Authored-By: Claude Sonnet 5 --- include/morph/core/bridge.hpp | 15 ++++--- include/morph/core/remote.hpp | 38 ++++++++++++----- tests/test_shared_instances.cpp | 72 +++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 15 deletions(-) diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index b9c2cec3..3bf96641 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -290,11 +290,16 @@ class Bridge { return; } auto const previous = ::morph::exec::detail::ModelId{binding->currentId.load()}; - // IBackend::attachModel acquires the replacement instance before - // releasing `previous` (see its doc comment), so a throwing acquire - // never touches `previous` -- it stays exactly as live, and the - // binding (left unchanged below) still correctly points at it. Only a - // successful attach updates contextKey/primary/currentId. + // IBackend::attachModel's default (local) implementation and + // RemoteServer's wire-level attach handling both acquire the + // replacement before releasing `previous`, so a throwing acquire + // leaves `previous` untouched except in one narrow, harmless case: a + // remote re-point whose connection scope closes concurrently with + // the attach, where `previous` may already be released by the time + // the reply reports failure -- but no further request on this + // binding will ever run at that point either, so nothing is actually + // lost from the caller's perspective. Unbind first is therefore + // unnecessary: publish only on success. auto newId = loadBackend()->attachModel(binding->typeId, binding->modelFactory, {.contextKey = primary, .primary = primary}, previous); binding->contextKey = primary; diff --git a/include/morph/core/remote.hpp b/include/morph/core/remote.hpp index ebe5e13d..78a81c76 100644 --- a/include/morph/core/remote.hpp +++ b/include/morph/core/remote.hpp @@ -724,12 +724,19 @@ class RemoteServer : public std::enable_shared_from_this { /// @param env Decoded request; uses `typeId`, `primary`, `contextKey`, `callId`. /// @param reply Reply sink; always invoked exactly once. /// @param cid Connection scope, or `0` for unscoped. - /// @param releaseCurrent Instance to release *after* acquiring the target - /// (an `attach` re-point), or `ModelId{0}`. Acquire - /// runs first so a same-key re-attach lands on the - /// same instance instead of destroying and - /// recreating it, and so a failing acquire never - /// touches it. + /// @param releaseCurrent Instance to release once the target is + /// confirmed acquired or confirmed about to be + /// created (an `attach` re-point), or `ModelId{0}`. + /// A throwing *construction* (`_registry.create`) + /// never touches it. Once construction succeeds, + /// release happens in the same locked section as + /// the `maxLiveModels` admission check, so a sole + /// holder's release frees the slot the re-point + /// itself needs rather than losing it to the cap; + /// the only remaining post-release failure is the + /// connection's own scope having closed + /// concurrently, in which case no further request + /// on it will run anyway. void acquireSharedInstance(const ::morph::wire::Envelope& env, const std::function& reply, ConnectionId cid, ::morph::exec::detail::ModelId releaseCurrent) { LimitPolicy limits; @@ -755,10 +762,6 @@ class RemoteServer : public std::enable_shared_from_this { return; } } - if (releaseCurrent.v != 0U) { - std::scoped_lock const lock{_regMtx}; - releaseScopedLocked(releaseCurrent, cid); - } // Directory miss. Construct outside the lock, exactly as the private // register path does, then re-check under the insert lock: a concurrent // request for the same key may have won the race while we built ours. @@ -768,8 +771,23 @@ class RemoteServer : public std::enable_shared_from_this { { std::scoped_lock const lock{_regMtx}; if (attachExistingLocked(dirKey, env, reply, cid)) { + if (releaseCurrent.v != 0U) { + releaseScopedLocked(releaseCurrent, cid); + } return; } + // Confirmed miss: release the old instance now, in the same + // locked section as the maxLiveModels admission check, so a sole + // holder's release frees exactly the slot this re-point needs + // rather than losing it to the cap in between -- the property + // the single `attach` wire request exists to provide. The only + // way admission can still fail after this is a concurrently + // closed connection scope (noteScopeAttachLocked below), which + // makes "stranding" moot: no further request on that connection + // will ever run anyway. + if (releaseCurrent.v != 0U) { + releaseScopedLocked(releaseCurrent, cid); + } if (limits.maxLiveModels != 0 && _models.size() >= limits.maxLiveModels) { reply(::morph::wire::encode(::morph::wire::makeErr("too many models", env.callId))); return; diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp index dc10c7e6..e62b71ff 100644 --- a/tests/test_shared_instances.cpp +++ b/tests/test_shared_instances.cpp @@ -195,6 +195,24 @@ BRIDGE_MODEL_KEY(ShiHydrateModel, ShiHydrateFail, &ShiHydrateFail::id); BRIDGE_KEY_FROM(ShiHydrateOk, &ShiHydrateOk::id); // NOLINTEND(misc-use-internal-linkage) +// NOLINTBEGIN(misc-use-internal-linkage) +/// A model whose *construction* can be made to fail on demand -- distinct from +/// `ShiHydrateModel`, which fails its first *action* after already existing. +/// Used to prove `acquireSharedInstance`'s directory-miss path never releases +/// `releaseCurrent` before `_registry.create` has actually succeeded: a +/// throwing construction must leave the old instance completely untouched. +struct ShiThrowSecondModel { + static inline std::atomic throwOnConstruct{false}; + ShiThrowSecondModel() { + if (throwOnConstruct.exchange(false)) { + throw std::runtime_error("simulated construction failure"); + } + } +}; + +BRIDGE_REGISTER_MODEL(ShiThrowSecondModel, "SHI_ThrowSecondModel") +// NOLINTEND(misc-use-internal-linkage) + namespace { using morph::bridge::AllowShared; @@ -666,6 +684,60 @@ TEST_CASE("a shared register is refused once the server is at its model cap", "[ REQUIRE(again.modelId == firstReply.modelId); } +TEST_CASE("a shared handler re-pointing to a new key does not lose its slot to maxLiveModels", "[shared-instances]") { + morph::testing::InlineExecutor exec; + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool); + server->setLimitPolicy({.maxLiveModels = 1}); + + Bridge bridge{std::make_unique(*server)}; + BridgeHandler handler{bridge, &exec}; + + // Fills the server's one slot. + settle(handler.execute(ShiAddTo{.id = 1, .amount = 5})); + REQUIRE(handler.primary().value_or(-1) == 1); + + // Re-pointing to a *different* key must release key 1's slot and use it + // for key 2 -- the whole point of the single `attach` wire request being + // atomic. This must succeed, not strand the handler. + settle(handler.execute(ShiAddTo{.id = 2, .amount = 7})); + REQUIRE(handler.primary().value_or(-1) == 2); + REQUIRE(settle(handler.execute(ShiPeek{})).value == 7); +} + +TEST_CASE("a throwing construction during attach's re-point never releases the old instance", + "[shared-instances]") { + // Discriminates the fix directly (unlike the maxLiveModels re-point test + // above, which happens to pass under both the old and new ordering for + // this simple single-threaded case): the old code released + // `releaseCurrent` in a standalone step *before* `_registry.create`, so a + // throwing construction on a directory miss would already have destroyed + // the old instance by the time the exception propagated. The fixed code + // only releases after construction has succeeded, so a throwing + // construction must leave the old instance completely intact. + morph::exec::ThreadPoolExecutor pool{2}; + auto server = std::make_shared(pool); + + auto firstReply = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeAttach("SHI_ThrowSecondModel", "A")))); + REQUIRE(firstReply.kind == "ok"); + auto const midA = firstReply.modelId; + + // Force the *next* construction (the re-point's directory-miss path, + // building the replacement for key "B") to throw. + ShiThrowSecondModel::throwOnConstruct.store(true); + auto second = morph::wire::decode(server->handleInline( + morph::wire::encode(morph::wire::makeAttach("SHI_ThrowSecondModel", "B", midA)))); + REQUIRE(second.kind == "err"); + + // The instance under "A" must still be exactly the one from the first + // attach -- not destroyed and replaced by a fresh one. + auto again = morph::wire::decode( + server->handleInline(morph::wire::encode(morph::wire::makeAttach("SHI_ThrowSecondModel", "A")))); + REQUIRE(again.kind == "ok"); + REQUIRE(again.modelId == midA); +} + TEST_CASE("attach and instances are refused by an authorizer that denies", "[shared-instances]") { morph::exec::ThreadPoolExecutor pool{2}; auto server = std::make_shared(pool, std::make_shared()); From 91de1033595ccf8e4cf6e3400dbdf7014abe1d2c Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 14:41:44 +0300 Subject: [PATCH 38/42] fix(remote): rename dispatchExecute's lambda-local mid to avoid shadowing The strand-posted lambda in dispatchExecute reconstructed a local `mid` from env.modelId, shadowing the outer `mid` used by `_strand.post(mid, ...)`. Both hold the identical value, so this was harmless in practice, but GCC's -Wshadow (enabled with -Werror in this project) may flag it even though Clang's captured-variable shadowing rules are more lenient -- a preventive rename to targetMid, scoped to the lambda body only. Co-Authored-By: Claude Sonnet 5 --- include/morph/core/remote.hpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/include/morph/core/remote.hpp b/include/morph/core/remote.hpp index 78a81c76..011b67b7 100644 --- a/include/morph/core/remote.hpp +++ b/include/morph/core/remote.hpp @@ -1258,7 +1258,7 @@ class RemoteServer : public std::enable_shared_from_this { } _strand.post(mid, [self, env = std::move(env), holder = std::move(holder), complete, timeoutHandle]() mutable { - ::morph::exec::detail::ModelId const mid{env.modelId}; + ::morph::exec::detail::ModelId const targetMid{env.modelId}; auto const start = std::chrono::steady_clock::now(); auto const spanId = ::morph::observe::detail::beginSpan(env.session.requestId, env.modelType, env.actionType); @@ -1295,7 +1295,7 @@ class RemoteServer : public std::enable_shared_from_this { ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeLatencyMs, elapsedMs, tags); { std::scoped_lock const lock{self->_regMtx}; - self->_firstActionPending.erase(mid); + self->_firstActionPending.erase(targetMid); } complete(::morph::wire::encode(::morph::wire::makeOk(env.callId, std::move(result)))); } catch (const std::exception& exc) { @@ -1314,9 +1314,10 @@ class RemoteServer : public std::enable_shared_from_this { ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeErrors, 1.0, tags); { std::scoped_lock const lock{self->_regMtx}; - if (auto iter = self->_firstActionPending.find(mid); iter != self->_firstActionPending.end()) { + if (auto iter = self->_firstActionPending.find(targetMid); + iter != self->_firstActionPending.end()) { self->_firstActionPending.erase(iter); - self->_poisoned.insert(mid); + self->_poisoned.insert(targetMid); } } complete(::morph::wire::encode(::morph::wire::makeErr(exc.what(), env.callId))); From 43012dcd353cfb753eca1672171db3022f32c5e6 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 14:41:49 +0300 Subject: [PATCH 39/42] docs(bridge): narrow assignHandlerPrimary's doc comment to its actual guard The doc comment implied a guard against promoting onto a target key already held by another instance. The only guard that actually exists is against re-keying a binding that already holds a different real primary (`!binding->primary.empty()`) -- the backend's own assignPrimary silently declines the already-taken-target-key case with no way for this method to observe it. Reworded to describe only the real guard and recorded the residual gap (the binding's cached primary can desync from what the backend actually filed it under) as a tracked follow-up. No code change. Co-Authored-By: Claude Sonnet 5 --- include/morph/core/bridge.hpp | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index 3bf96641..988f84da 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -327,13 +327,24 @@ class Bridge { /// @brief Files @p binding's current instance under @p primary, in place. /// /// The instance keeps everything the creating action just did — nothing is - /// re-created and nothing is stranded. A no-op if another instance already - /// holds that key (the existing holder always wins) or if @p binding - /// already holds a *different* real key — `IBackend::assignPrimary` only - /// ever promotes a still-anonymous instance, so the locally cached primary - /// must not race ahead of it: updating it here regardless would make - /// `primary()` report a key the backend never actually filed the instance - /// under. + /// re-created and nothing is stranded. A no-op if @p binding already holds + /// a different real primary (the locally cached primary must not race + /// ahead of the backend's own refusal to re-key an already-keyed + /// instance — see `IBackend::assignPrimary`). + /// + /// Known gap: if @p binding was still anonymous but the *target* key is + /// already held by a different instance, the backend's `assignPrimary` + /// silently declines to promote (the existing holder always wins), but + /// this method has no way to learn that and still caches @p primary as + /// though the promotion succeeded — `binding->primary()` can then report + /// a key the backend never actually filed this instance under, and a + /// same-key `attach()` after that becomes a silent no-op (the "already + /// primary == primary" guard in `attachHandler`), so the binding can + /// never reach the instance actually holding that key. Closing this + /// requires `assignPrimary`'s outcome to become observable (e.g. a + /// `bool` return threaded across every `IBackend` implementation and, for + /// wire backends, a reply field) — tracked as a follow-up, not fixed + /// here. /// @tparam Model Concrete model type. /// @param binding Shared binding whose instance is being promoted. /// @param primary Canonical string encoding of the key to file it under. From 09034f0ae0185954b4d7db74062006d0bfcd6286 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 14:41:56 +0300 Subject: [PATCH 40/42] docs(shared-instances): correct the poisoning failure-mode description The Failure-modes bullet about attaching to a key whose entity does not exist said a failed first action "releases" the instance -- it doesn't. Verified against LocalBackend's HydrationFlags/_hydrationFlags and RemoteServer's _firstActionPending/_poisoned: the instance is marked and evicted from the directory lazily, on the next attach to that key, not destroyed immediately; it stays alive (still counting against maxLiveModels) until whoever created it releases it normally. The handler that hit the failure keeps its broken instance, since attachHandler's same-primary no-op guard means retrying the same keyed action never re-runs the backend attach. Also add a Limitations bullet: poisoning is checked only against already-settled first-action failures, not retroactively, so two attaches racing the same not-yet-existing key can both land on the same instance while its first action is still in flight. Co-Authored-By: Claude Sonnet 5 --- docs/spec/core/shared_instances.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/spec/core/shared_instances.md b/docs/spec/core/shared_instances.md index 33362762..74f2c836 100644 --- a/docs/spec/core/shared_instances.md +++ b/docs/spec/core/shared_instances.md @@ -379,10 +379,20 @@ strictly reduces pressure on it. observable — `primary()` returning empty after a successful execute is the signal — rather than silently degrading. - **Attaching to a key whose entity does not exist.** The directory happily - creates an instance; hydration fails inside the model and the action completes - through `onError`. The instance must not be left in the directory in a - half-hydrated state, so a failed *first* action on a freshly created shared - instance releases it. + creates an instance; hydration fails inside the model and the action + completes through `onError`. The instance must not be handed to a *new* + attacher in that half-hydrated state, so its very first action's outcome is + tracked: if it fails, the instance is marked and evicted from the directory + **the next time anyone else attaches to that key** — not immediately. The + instance itself is not destroyed; it stays alive (and still counts against + `LimitPolicy::maxLiveModels`) until whoever created it releases it + normally, the same as any other instance. The handler that hit the failure + does not self-heal: its primary is already set to the poisoned key, so + retrying the same keyed action re-points nowhere (`attachHandler`'s + no-op-on-same-primary guard skips the backend entirely) — it keeps its + broken instance until it releases and re-attaches from scratch. A + *different* handler attaching to the same key afterward is unaffected and + gets a fresh instance. - **`instances()` raced against `attach`.** Documented as inherent: the snapshot is stale on arrival. An `attach` to a key from a stale list is not an error — it simply creates the instance again. @@ -419,6 +429,12 @@ strictly reduces pressure on it. - **Enumeration is per model type and unfiltered.** No paging, no predicate; a model type with very many live instances returns all of them. - **`instances()` discloses live keys** to any principal `authorize` admits. +- **A second attacher can still land on an instance whose first action is + still in flight.** Poisoning is only checked at attach time, against + instances whose first action has already settled and failed; it is not + retroactive. Two attaches racing the same not-yet-existing key can both + reach the same instance while its first action is still running, and only + learn together whether it succeeded. ## Non-goals From cc58ab6591e47e24f4bbf34b1c16a3ca955c29f2 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 14:42:00 +0300 Subject: [PATCH 41/42] docs(wire): stop saying contextKey is register-only in the reference tables Task 4 already updated the "contextKey -- stable identity" prose to state contextKey is carried on both register and attach, but the discriminator summary table and the Envelope field-reference table still said "register" only, contradicting it. Updated both rows to mention attach alongside register. The discriminator table's pre-existing lack of any row for attach/assign/instances is a larger, separate gap (covered by shared_instances.md) and is intentionally left untouched. Co-Authored-By: Claude Sonnet 5 --- docs/spec/core/wire.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/spec/core/wire.md b/docs/spec/core/wire.md index 4ad7cf8e..0b4817a6 100644 --- a/docs/spec/core/wire.md +++ b/docs/spec/core/wire.md @@ -25,7 +25,7 @@ their `kind` needs and leave the rest as default-constructed values. | `kind` | Direction | Purpose | Key fields | |---|---|---|---| -| `"register"` | request | Client requests model creation. | `typeId`, `contextKey` (optional stable identity) | +| `"register"` | request | Client requests model creation. | `typeId`, `contextKey` (optional stable identity, also carried on `"attach"`) | | `"deregister"` | request | Client destroys an instance. | `modelId` | | `"execute"` | request | Client dispatches an action. | `callId`, `modelId`, `modelType`, `actionType`, `body`, `session` | | `"hello"` | request | Client announces its protocol version, once per connection, before any `register`/`execute`. See [Protocol version negotiation](#protocol-version-negotiation). | `protocolVersion` | @@ -327,7 +327,7 @@ is: | `kind` | `std::string` | `""` | All — the discriminator. | | `callId` | `uint64_t` | `0` | `"execute"`, `"ok"`, `"err"` — correlation id for async matching. | | `typeId` | `std::string` | `""` | `"register"` — model type id. | -| `contextKey` | `std::string` | `""` | `"register"` — stable identity for the new instance. | +| `contextKey` | `std::string` | `""` | `"register"`, `"attach"` — stable identity for the new instance. | | `modelId` | `uint64_t` | `0` | `"deregister"`, `"execute"`, `"ok"(register)` — instance id. | | `modelType` | `std::string` | `""` | `"execute"` — routing key for `ActionDispatcher`. | | `actionType` | `std::string` | `""` | `"execute"` — second routing key. | From d056abcc2735501f5e5d3139d7d91b19698486af Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 16:47:20 +0300 Subject: [PATCH 42/42] fix(tests): suppress a clang-only false-positive warning in the guard-page test Assigning to sa.sa_handler expands glibc's macro (sa_handler -> __sigaction_handler.sa_handler), which clang's -Wdisabled-macro-expansion flags as a false positive on Linux under -Weverything -Werror; the BSD-derived this test was authored and verified against locally doesn't hit it. Scope the suppression to just this one assignment. Co-Authored-By: Claude Sonnet 5 --- tests/test_bridge_lifetime.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_bridge_lifetime.cpp b/tests/test_bridge_lifetime.cpp index e1d7a197..53185ff9 100644 --- a/tests/test_bridge_lifetime.cpp +++ b/tests/test_bridge_lifetime.cpp @@ -462,7 +462,21 @@ TEST_CASE("Bridge: hasSubscribers is not read once the bridge is destroyed (guar REQUIRE(mprotect(region, pageSize, PROT_NONE) == 0); struct sigaction sa {}; + // glibc's defines `sa_handler` as a macro + // (`__sigaction_handler.sa_handler`) for POSIX compatibility; clang's + // -Wdisabled-macro-expansion flags the resulting member-access expansion + // as a false positive (the code is correct and portable -- this is a + // known rough edge between clang's macro-hygiene checker and glibc's + // headers, not a bug here) that only reproduces on Linux, not on the + // BSD-derived this test was authored and verified against. +#if defined(__clang__) +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdisabled-macro-expansion" +#endif sa.sa_handler = guardPageFaultHandler; +#if defined(__clang__) +#pragma clang diagnostic pop +#endif sigemptyset(&sa.sa_mask); sa.sa_flags = 0; struct sigaction oldSegv {};