From eb15fae4741677542e3839358d79db6ea2fc3162 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 23 Sep 2026 20:29:38 +0200 Subject: [PATCH 1/6] docs/spec: state the constraint, drop the citation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 327 tracker references across 24 spec files — 279 `morph#NNN` and 48 of the `issue #NNN` / bare `#NNN` forms the first pass's grep did not see — replaced by the reasoning they stood in for, per AGENTS.md "Comments and documentation". Most of the diff is rewriting, not deleting. A paragraph whose only support was a ticket number has the support restated: "the same check-then-call shape, elsewhere in `Bridge` — issue #489" becomes the four dispositions and why each site can or cannot take the lifetime gate; backend.md's "What was wrong with the old shape" becomes "Why one bind virtual and not four", which argues the alternative's two costs in the present tense instead of narrating its removal. Every measured block stays whole: executor.md's strand-recycling allocation table, security.md's `gai_strerror` mapping evidence, testing_strategy.md's 302x polling-step swing, registry.md's transparent-key census, backend.md's `ActionCall` round-trip figures. Past-tense narration around them is recast as the condition the number belongs to, and the revisions the measurements were taken on come out with the ticket numbers. backend.md's "Migration status" table — seven landed pull requests — is gone; what it carried that is still true (default `bindModel` routes to the synchronous verb, the `parkIfInFrame` arm is measured-unreachable and kept anyway, the `inlineExecutor()` choice) is restated as current behaviour. `UAX #44` in forms.md is a Unicode standard, not a tracker, and stays. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW --- docs/spec/concurrency_and_lifetimes.md | 131 +++--- docs/spec/core/backend.md | 526 +++++++++++------------- docs/spec/core/bridge.md | 87 ++-- docs/spec/core/callback_scope.md | 25 +- docs/spec/core/completion.md | 87 ++-- docs/spec/core/executor.md | 61 ++- docs/spec/core/file_io_ops.md | 19 +- docs/spec/core/registry.md | 41 +- docs/spec/core/shared_instances.md | 28 +- docs/spec/core/wire.md | 41 +- docs/spec/error_handling.md | 11 +- docs/spec/forms/choice.md | 10 +- docs/spec/forms/forms.md | 136 +++--- docs/spec/forms/instance_constraints.md | 4 +- docs/spec/forms/sections.md | 13 +- docs/spec/forms/views.md | 8 +- docs/spec/forms/widget_hints.md | 14 +- docs/spec/journal/journal.md | 85 ++-- docs/spec/offline/offline.md | 108 +++-- docs/spec/security.md | 18 +- docs/spec/testing_strategy.md | 170 ++++---- docs/spec/util/datetime.md | 2 +- docs/spec/util/quantity_type.md | 61 ++- docs/spec/util/rational.md | 51 ++- 24 files changed, 819 insertions(+), 918 deletions(-) diff --git a/docs/spec/concurrency_and_lifetimes.md b/docs/spec/concurrency_and_lifetimes.md index 526a04ca2..a0fb2a089 100644 --- a/docs/spec/concurrency_and_lifetimes.md +++ b/docs/spec/concurrency_and_lifetimes.md @@ -234,11 +234,11 @@ across threads by construction (see the next section): it reports an instant tha has already passed. That is fine for gating *delivery* of a callback — a suppressed callback simply does not run — and the bridge still uses `liveness()` for exactly that. It is not fine for gating a *member call on the `Bridge`*. -`~BridgeHandler` used a bare `active()` check until issue #486, where a -`shared_ptr` kept alive by its own dispatched completions was -released on a worker-pool thread while the owning thread ran `~App`: the check -passed, the `Bridge` finished being destroyed, and `Bridge::deregisterHandler` -then iterated the freed `_handlers`. The gate turns check-then-call into one +A bare `active()` check in `~BridgeHandler` admits this: a +`shared_ptr` kept alive by its own dispatched completions is +released on a worker-pool thread while the owning thread runs `~App`, the check +passes, the `Bridge` finishes being destroyed, and `Bridge::deregisterHandler` +then iterates the freed `_handlers`. The gate turns check-then-call into one indivisible step. **`~Bridge` therefore blocks**, like `~StrandExecutor` above and for the same @@ -256,19 +256,18 @@ thread that is already inside `~Bridge`** — destroying a handler from within self-deadlock on the gate, exactly as re-entering any exclusively-held mutex is. No framework path does this; a caller that arranges it is outside the contract. -### The same check-then-call shape, elsewhere in `Bridge` — issue #489 +### The same check-then-call shape, elsewhere in `Bridge` -`~BridgeHandler` was one check-then-call site of this shape; issue #489 named -four more inside `Bridge` itself, and a follow-up audit of that issue found three -further ones on the `IBackend` `*Async` reply path — each gating a callback on -`liveness()` (or an equivalent snapshot) and then touching `this`. Not all of -them can take `BridgeLifetime`'s gate the way `~BridgeHandler` does — the gate -makes `~Bridge` block for as long as the gated span takes, and a span that can -call into consumer-supplied code or a backend's blocking registration path turns -that bounded wait into an unbounded one. Four dispositions, by site: +`~BridgeHandler` is one check-then-call site of this shape. Four more sit inside +`Bridge` itself, and three on the bind/promote reply path — each gating a +callback on `liveness()` (or an equivalent snapshot) and then touching `this`. +Not all of them can take `BridgeLifetime`'s gate the way `~BridgeHandler` does: +the gate makes `~Bridge` block for as long as the gated span takes, and a span +that can call into consumer-supplied code or a backend's blocking registration +path turns that bounded wait into an unbounded one. Four dispositions, by site: - **`executeVia()`'s `.then`/`.onError` continuations.** `_pendingCalls` and - `_subscriptions` are now heap-allocated (`shared_ptr`, like `BridgeLifetime` + `_subscriptions` are heap-allocated (`shared_ptr`, like `BridgeLifetime` itself) and captured by value into the continuations, rather than reached through `this`. Once pinned that way, touching them needs no liveness check at all: `_pendingCalls` is decremented unconditionally, and @@ -286,17 +285,15 @@ that bounded wait into an unbounded one. Four dispositions, by site: Safe to hold the gate here: nothing in that span calls into consumer code or a blocking backend path, only a mutex and a pointer comparison. - Since morph#593 that callback may also run **on the registering thread - itself**: unless the backend answers `IBackend::BindWait::kCallerMustNotBlock`, + That callback may also run **on the registering thread itself**: unless the + backend answers `IBackend::BindWait::kCallerMustNotBlock`, `registerHandlerImpl` waits for the bind completion and then delivers the - outcome from its own frame, so `registerHandler` returns a bound handler. That - reinstates, for one statement, exactly the blocking window every backend had - before morph#568, when the fallback was the synchronous - `registerModelWithContext`: a thread parked inside `registerHandler` is a - thread not running `~Bridge`, and a *different* thread destroying the `Bridge` - while `registerHandler` is still on this one was already a misuse then and is - no more possible now. What is new is only that the parking is visible in - `Bridge` rather than inside the backend verb. The two + outcome from its own frame, so `registerHandler` returns a bound handler. The + parked statement is a blocking window, and a deliberate one: a thread parked + inside `registerHandler` is a thread not running `~Bridge`, and a *different* + thread destroying the `Bridge` while `registerHandler` is still on this one is + a misuse either way. The window is visible in `Bridge` rather than hidden + inside a backend verb, which is the point of putting it here. The two `kCallerMustNotBlock` backends never park at all, which is the point: for `QtWebSocketBackend` under `asyncRegistrationEnabled` the reply arrives on the parked thread's own event loop, so parking would not be a slow teardown but a @@ -311,17 +308,15 @@ that bounded wait into an unbounded one. Four dispositions, by site: event that could run the destructor — that is a self-deadlock, not a slow teardown. - morph#615 removed the worst version of that span rather than the span - itself. The handler used to call `registerModelWithContext`/ - `registerModelShared`, which for `QtWebSocketBackend` block on a nested - `QEventLoop`; it now calls `bindModel` and consults - `IBackend::bindWaitPolicy()`, so a backend that says `kCallerMustNotBlock` - is not waited for at all and the handler returns promptly. A - `kCallerMayBlock` backend is still waited out, on the transport thread, - under both bridge mutexes — a bounded round trip by that backend's own - contract, but still a span a `BridgeLifetime` gate must not cover. So the - site stays on `liveness()`, and the residual scope of issue #489 stays - open. + The span is kept as short as it can be: the handler calls `bindModel` and + consults `IBackend::bindWaitPolicy()`, so a backend that says + `kCallerMustNotBlock` is not waited for at all and the handler returns + promptly. Calling the synchronous `registerModelWithContext`/ + `registerModelShared` instead would block on a nested `QEventLoop` for + `QtWebSocketBackend`. A `kCallerMayBlock` backend is still waited out, on the + transport thread, under both bridge mutexes — a bounded round trip by that + backend's own contract, but still a span a `BridgeLifetime` gate must not + cover. So the site stays on `liveness()`, and this window stays open. - **The bind/promote reply continuations** — `attachHandlerAsync`, `ensureBoundAsync` and `assignHandlerPrimary`. (The fourth registration site, `registerHandlerImpl`, is covered by the `BridgeLifetime` bullet above and is @@ -330,17 +325,16 @@ that bounded wait into an unbounded one. Four dispositions, by site: is present in the source. What closes the window is not a gate but the thread the continuation is delivered on. - Until morph#571 that thread was a **contract on the backend**, stated in the - `*Async` twins' doc comments: a backend overriding one had to deliver its - callbacks from a thread on which `~Bridge` could not run concurrently. - morph#568 moved every site onto `IBackend::bindModel`/`promoteModel` and - morph#571 deleted the twins, so there is no such contract left to state — but - the three sites name `exec::detail::inlineExecutor()` as the delivery - executor, which reproduces the old delivery thread exactly: the continuation - runs wherever the backend settled. - - **Since morph#588 the window is closed for a `Bridge` that was given an - executor, and unchanged for one that was not.** The `bindModel`/ + The alternative is to make that thread a **contract on the backend**: every + backend overriding an async registration verb would have to deliver its + callbacks from a thread on which `~Bridge` cannot run concurrently. Instead + every site goes through `IBackend::bindModel`/`promoteModel`, which takes the + delivery executor as an argument, and the three sites name + `exec::detail::inlineExecutor()`: the continuation runs wherever the backend + settled. + + **The window is closed for a `Bridge` that was given an executor, and open + for one that was not.** The `bindModel`/ `promoteModel` argument is still `inlineExecutor()` — deliberately, because a reply that settles inside the dispatch frame must reach `parkIfInFrame` there, or `registerHandler()` stops being synchronous and `awaitHandoff` @@ -358,34 +352,31 @@ that bounded wait into an unbounded one. Four dispositions, by site: `attachModel` round trip — the same shape of objection that rules a gate out for the reconnect handler. - **What changed with the removal is who could get it wrong, not whether it can - be wrong.** A backend that settles a `bindModel` completion on its own - transport thread reopens morph#486's use-after-free here for a bridge with no - `bridgeExec`; the difference morph#571 made is that the delivery thread is a - value one call site produces rather than an obligation on fifteen backend - authors, so closing it was a change in one place. morph#588 made it: the - choice is a constructor argument, and the residual exposure is the embedder's - own — supplying an executor on a thread unrelated to teardown satisfies the - type and closes nothing, which is stated where the argument is documented - rather than left to be discovered. + **What the executor argument changes is who can get it wrong, not whether it + can be wrong.** A backend that settles a `bindModel` completion on its own + transport thread reproduces the `~BridgeHandler` use-after-free here for a + bridge with no `bridgeExec`. Because the delivery thread is a value one call + site produces rather than an obligation on fifteen backend authors, closing it + is a change in one place — a constructor argument. The residual exposure is + the embedder's own: supplying an executor on a thread unrelated to teardown + satisfies the type and closes nothing, which is stated where the argument is + documented rather than left to be discovered. The structural surface that replaces these four hooks — `IBackend::bindModel`/`promoteModel` — takes the executor the continuation is delivered on as an argument, so the delivery thread is chosen by the caller, which knows what its own teardown looks like, instead of by the backend, which - does not. `Bridge` now reaches it at all five sites (morph#568, morph#615). - **That does not close the window above, and morph#568 does not claim it - does**: `Bridge` owns no event loop, so the executor it names is - `exec::detail::inlineExecutor()` — "deliver wherever you settled", which is - what the prose contract already required. What changed is where the decision - lives: one value produced at five `Bridge` call sites, rather than a - documented obligation on every `IBackend` implementor. morph#588 then gave - `Bridge` an executor of its own and used it for the late replies — not in - place of the `inlineExecutor()` argument, which the in-frame settle needs, so - the two cases are now told apart by the handoff rather than by the executor. - See - [core/backend.md](core/backend.md#the-structural-registration-surface--bindmodel-and-promotemodel), - [core/bridge.md](core/bridge.md) and morph#522. + does not. `Bridge` reaches it at all five sites. **That alone does not close + the window above**: `Bridge` owns no event loop, so the executor it names is + `exec::detail::inlineExecutor()` — "deliver wherever you settled". What the + argument buys is where the decision lives: one value produced at five `Bridge` + call sites, rather than a documented obligation on every `IBackend` + implementor. The `Bridge`'s own executor is what closes it, and it is used for + the late replies only — not in place of the `inlineExecutor()` argument, which + the in-frame settle needs, so the two cases are told apart by the handoff + rather than by the executor. See + [core/backend.md](core/backend.md#the-structural-registration-surface--bindmodel-and-promotemodel) + and [core/bridge.md](core/bridge.md). `switchBackend()` and `whenBound()` were audited for the same shape and do not have it. Both are ordinary synchronous member functions called by the bridge's diff --git a/docs/spec/core/backend.md b/docs/spec/core/backend.md index bf7394789..5241b96de 100644 --- a/docs/spec/core/backend.md +++ b/docs/spec/core/backend.md @@ -26,7 +26,7 @@ and react to backend changes. ## Contents - [The dispatch struct — `ActionCall`](#the-dispatch-struct--actioncall) - - [Why the callables are function pointers (morph#572)](#why-the-callables-are-function-pointers-morph572) + - [Why the callables are function pointers](#why-the-callables-are-function-pointers) - [Lifetime contract](#lifetime-contract) - [The abstract interface — `IBackend`](#the-abstract-interface--ibackend) - [Connect/disconnect notifications](#connectdisconnect-notifications) @@ -79,17 +79,17 @@ There is one member function, `serializeBody()`, which pairs it rather than invoking the pointer itself, so the borrow is closed in one place instead of three. -### Why the callables are function pointers (morph#572) +### Why the callables are function pointers `Bridge::executeVia` builds an `ActionCall` on **every** call, including calls a `LocalBackend` serves and that therefore never touch `serializeAction` or -`deserializeResult`. The earlier shape — three `std::function`s, each capturing -a `shared_ptr` — made that apparatus cost heap allocations whichever -path the call took: libstdc++'s small-object buffer is available only to a +`deserializeResult`. The obvious alternative — three `std::function`s, each +capturing a `shared_ptr` — makes that apparatus cost heap allocations +whichever path the call takes: libstdc++'s small-object buffer is available only to a trivially copyable target, and a captured `shared_ptr` is not one, so each -stateful callable allocated. The two type ids were `std::string` copies of -compile-time constants and allocated whenever an id exceeded the 15-character -SSO threshold. +stateful callable allocated. Type ids carried as `std::string` copies of +compile-time constants allocate in the same way whenever an id exceeds the +15-character SSO threshold, so they are `string_view`s here. The behaviour of each callable is a constant of `(Model, Action)`, not of the call, so it is *addressed* rather than copied: a stateless function pointer, @@ -97,13 +97,13 @@ parameterised on the action it operates on. The action object itself still needs a home — it is the one genuinely per-call thing — and that home is `action`, a single `make_shared`, which the type erasure needed anyway. -**Measured** on `master` @ `a9cb5649` with `tests/bench/bench_dispatch_allocations.cpp` +**Measured** with `tests/bench/bench_dispatch_allocations.cpp` (`morph_bench_alloc`, clang 22.1.8 / libstdc++ 16.2.1, Release, 15 processes -per configuration): a local `Ping -> Pong` round trip fell from a median of -**16.98** heap allocations per call (range 16.89–17.05) to **13.79** -(range 13.71–14.03), and from 1245.7 to 1151.1 bytes per call. The three -allocations removed are the `modelTypeId` string and the `serializeAction` and -`localOp` closure targets. +per configuration), the `std::function` shape against this one: a local +`Ping -> Pong` round trip costs a median of **16.98** heap allocations per call +(range 16.89–17.05) against **13.79** (range 13.71–14.03), and 1245.7 against +1151.1 bytes per call. The three allocations are the `modelTypeId` string and +the `serializeAction` and `localOp` closure targets. ### Lifetime contract @@ -129,7 +129,7 @@ holds a `unique_ptr` and delegates all model operations to it. | Method | Purpose | |---|---| | `registerModel(typeId, factory)` | Registers a new model instance, returns its opaque `ModelId`. | -| `registerModelWithContext(typeId, factory, contextKey)` | Same as `registerModel`, additionally passes a stable identity (e.g. account id). Default implementation drops `contextKey` and forwards to `registerModel` — correct for `LocalBackend` where the factory closure already captures identity. Every backend whose instances live behind a wire protocol overrides it to carry `contextKey` across: `SimulatedRemoteBackend`, `SocketBackend` (morph#587) and `QtWebSocketBackend` (morph#594) all do. Not cosmetic — `RemoteServer::attachLogIfConfigured` skips the `LogProvider` lookup entirely on an empty `contextKey`, so a wire backend that drops the key leaves the instance with **no** action log rather than a log missing a field (morph#587). | +| `registerModelWithContext(typeId, factory, contextKey)` | Same as `registerModel`, additionally passes a stable identity (e.g. account id). Default implementation drops `contextKey` and forwards to `registerModel` — correct for `LocalBackend` where the factory closure already captures identity. Every backend whose instances live behind a wire protocol overrides it to carry `contextKey` across: `SimulatedRemoteBackend`, `SocketBackend` and `QtWebSocketBackend` all do. Not cosmetic — `RemoteServer::attachLogIfConfigured` skips the `LogProvider` lookup entirely on an empty `contextKey`, so a wire backend that drops the key leaves the instance with **no** action log rather than a log missing a field. | | `bindModel(request, cbExec)` | Acquires a model instance and returns a `Completion` delivered on `cbExec`. One verb covering `registerModelWithContext`, `registerModelShared` and `attachModel`, selected by the request's shape. The preferred surface — see [The structural registration surface](#the-structural-registration-surface--bindmodel-and-promotemodel). | | `promoteModel(request, cbExec)` | Files an already-live instance under a key and returns a `Completion` delivered on `cbExec`. The structural counterpart of `assignPrimary`. | | `bindWaitPolicy()` | Whether a caller may block its own thread until a `bindModel`/`promoteModel` completion settles. `BindWait::kCallerMayBlock` by default. The framework callers are `Bridge::registerHandlerImpl`, `Bridge::switchBackend`'s phase 1 and `Bridge::installReconnectHandler`'s handler; see [Waiting for a bind — `bindWaitPolicy`](#waiting-for-a-bind--bindwaitpolicy). | @@ -222,15 +222,11 @@ WASM client makes, and again on the first payload-keyed `execute()` a keyed screen makes, which attaches. That is the whole reason a non-blocking registration path exists; everything below this heading is a consequence of it. -The path is [`bindModel`/`promoteModel`](#the-structural-registration-surface--bindmodel-and-promotemodel). -Between morph#26 and morph#571 it was instead four optional `*Async` twins -beside the synchronous verbs, each returning a `bool` meaning "I accepted the -request and will call exactly one callback later" or "I have no such path, -call the synchronous verb instead". They are gone; what they were for, and -what was wrong with the shape, is -[What was wrong with the old shape](#what-was-wrong-with-the-old-shape) below. -Two consequences of that history are still load-bearing and are recorded here -rather than left to be rediscovered: +The path is [`bindModel`/`promoteModel`](#the-structural-registration-surface--bindmodel-and-promotemodel); +why it is one virtual rather than an optional non-blocking twin per verb is +[Why one bind virtual and not four](#why-one-bind-virtual-and-not-four) below. +Two consequences of it are load-bearing and are recorded here rather than left +to be rediscovered: **The gate is `asyncRegistrationEnabled`, not the surface.** `bindModel` is non-blocking *as a signature* on every backend, but only a backend that @@ -265,44 +261,38 @@ below for which backends answer which way. ## The structural registration surface — `bindModel` and `promoteModel` -Everything above this heading describes the **older** of two registration -surfaces. It still works, every backend in the tree still uses it, and nothing -about it has changed — but it is being retired by -[morph#522](https://github.com/LASTRADA-Software/morph/issues/522), whose -five-step set replaces it. This section describes what replaces it and why. - -### What was wrong with the old shape - -The shape being replaced was four optional `*Async` twins on `IBackend` — -`registerModelAsync`, `registerModelSharedAsync`, `attachModelAsync` and -`assignPrimaryAsync` — each sitting beside a synchronous verb and each -returning `bool`. morph#571 removed them; this section is kept because what -was wrong with them is what the replacement is shaped by. - -What was wrong was not the duplication. It was two things that are properties -of the *signatures*: - -1. **The continuation was optional.** `true` meant "I accepted the request and - will call exactly one callback later", `false` meant "I have no async path, - call the synchronous verb instead". Every call site therefore carried two - paths, and no backend could be partially migrated without the caller knowing - about it. `Bridge::attachHandlerAsync`, `ensureBoundAsync` and - `assignHandlerPrimary` each carried that second path, plus the - `detail::AsyncDispatchHandoff` machinery needed because the "async" verb - might also answer inline. - -2. **The delivery thread was prose.** All four twins shared one requirement: a - backend must not deliver `onRegistered`/`onError` on a thread from which - `~Bridge` can run concurrently. That was a contract on the *backend*, stated - in a `@note`, and nothing could check it — a backend that replied on its own - transport thread compiled, passed, and reopened morph#486's use-after-free. - The reason it mattered is on `Bridge`'s side and is unchanged: three of the - four continuations (`ensureBoundAsync`, `attachHandlerAsync` and - `assignHandlerPrimary`) test `CallbackToken::active()` and then dereference - `this`, and a `~Bridge` completing between those two steps is issue #486 — - the same check-then-act shape - [concurrency_and_lifetimes.md](../concurrency_and_lifetimes.md) describes. - `registerHandlerImpl`'s callback was the exception: it holds +Everything above this heading describes the synchronous registration surface. +It still works and every backend in the tree still implements it; this section +describes the non-blocking surface that sits beside it, and why it has the +shape it has. + +### Why one bind virtual and not four + +The obvious alternative is four optional non-blocking twins on `IBackend` — +one beside each of `registerModel`, `registerModelShared`, `attachModel` and +`assignPrimary` — each returning a `bool` meaning "I accepted the request and +will call exactly one callback later" or "I have no such path, call the +synchronous verb instead". The objection is not the duplication. It is two +properties of those *signatures*: + +1. **The continuation would be optional.** With `true` meaning "I accepted the + request" and `false` meaning "call the synchronous verb instead", every call + site carries two paths, no backend can be partially adopted without the + caller knowing about it, and each of `Bridge::attachHandlerAsync`, + `ensureBoundAsync` and `assignHandlerPrimary` has to carry that second path + plus handoff machinery for the case where the "async" verb answers inline. + +2. **The delivery thread would be prose.** All four twins would share one + requirement: a backend must not deliver `onRegistered`/`onError` on a thread + from which `~Bridge` can run concurrently. That is a contract on the + *backend* that nothing can check — a backend that replies on its own + transport thread compiles, passes, and reopens the use-after-free described + in [concurrency_and_lifetimes.md](../concurrency_and_lifetimes.md). The + reason it matters is on `Bridge`'s side: three of the four continuations + (`ensureBoundAsync`, `attachHandlerAsync` and `assignHandlerPrimary`) test + `CallbackToken::active()` and then dereference `this`, and a `~Bridge` + completing between those two steps is exactly that check-then-act shape. + `registerHandlerImpl`'s callback is the exception: it holds `detail::BridgeLifetime` across its whole touch of `this`, which is safe there because nothing inside that span calls into consumer code or a blocking backend path. The other three cannot take that same gate — it makes @@ -310,20 +300,20 @@ of the *signatures*: which the synchronous `Bridge::attachHandler` holds across a full `attachModel` round trip, unbounded on a wire backend. -### What replaces it +### What the surface is instead Two verbs, on `IBackend`, carrying every case the five acquire/promote verbs carry between them: | Verb | Signature | Replaces | |---|---|---| -| `bindModel` | `virtual Completion bindModel(BindRequest, IExecutor& cbExec)` | `registerModel`, `registerModelWithContext`, `registerModelShared`, `attachModel` — and the `*Async` twins beside them. | -| `promoteModel` | `virtual Completion promoteModel(PromoteRequest, IExecutor& cbExec)` | `assignPrimary`, and the `*Async` twin beside it. | +| `bindModel` | `virtual Completion bindModel(BindRequest, IExecutor& cbExec)` | `registerModel`, `registerModelWithContext`, `registerModelShared`, `attachModel` | +| `promoteModel` | `virtual Completion promoteModel(PromoteRequest, IExecutor& cbExec)` | `assignPrimary` | `BindRequest` carries the union of the three acquire verbs' parameters, and its *shape* — not the verb name — selects the behaviour: -| `primary` | `current` | Meaning | Legacy verb | +| `primary` | `current` | Meaning | Synchronous verb | |---|---|---|---| | empty | `ModelId{0}` | Private instance, never enters the shared directory. | `registerModelWithContext` | | non-empty | `ModelId{0}` | Register-or-attach on `(typeId, primary)`. | `registerModelShared` | @@ -353,7 +343,7 @@ thread an argument: unobservable failure the surface exists to remove — so "deliver nowhere" cannot be spelled. -What this does **not** do is make morph#486 impossible by itself. It does not +What this does **not** do is make the use-after-free impossible by itself. It does not add a lock, and it does not know what the caller's teardown looks like. What it changes is *who decides*: the choice of delivery thread moves from fifteen `IBackend` implementors, none of which knows anything about the caller's @@ -363,23 +353,23 @@ event-loop thread passes that thread's executor and the two-step check-then-dereference can no longer straddle a destructor, by construction rather than by the backend author having read a `@note`. -**`Bridge` is that caller for half of it, since morph#588.** Its five dispatch -sites still name `exec::detail::inlineExecutor()` on the `bindModel`/ -`promoteModel` call itself, and that is now a decision rather than an absence: +**`Bridge` is that caller for half of it.** Its five dispatch +sites name `exec::detail::inlineExecutor()` on the `bindModel`/ +`promoteModel` call itself, and that is a decision rather than an absence: an inline settle has to reach `Bridge::detail::parkIfInFrame` *inside* the dispatch frame, because that is what keeps `registerHandler()` synchronous for a backend that binds inline and what stops `detail::awaitHandoff` waiting on a -task only the waiting thread could run. What morph#588 added is an executor for -the other case — a reply that arrives after the dispatch frame has gone, which -is the only one with a thread left to choose. `Bridge`'s constructor takes an -optional `bridgeExec`, `detail::deliverLate` routes exactly those replies to -it, and a null one (the default) runs them inline, where they ran before. - -So the window morph#486 describes is **closed for an embedder that supplies an -executor whose thread also runs `~Bridge`** — the continuation and the +task only the waiting thread could run. The other case — a reply that arrives +after the dispatch frame has gone — is the only one with a thread left to +choose. `Bridge`'s constructor takes an optional `bridgeExec`, +`detail::deliverLate` routes exactly those replies to it, and a null one (the +default) runs them inline. + +So the check-then-dereference window is **closed for an embedder that supplies +an executor whose thread also runs `~Bridge`** — the continuation and the destructor are then two tasks on one thread and cannot interleave — and -**unchanged for one that does not**, which is every caller that has not been -updated. What is no longer true is that `Bridge` has nothing to name. For +**open for one that does not**, which is every caller that names no executor. +For `QtWebSocketBackend` the safety is the same by-construction safety it always had — it must itself be used from the Qt event loop thread and settles every reply from `onTextMessage` on that same thread, so the check and the use cannot @@ -387,12 +377,12 @@ straddle a destructor. Its two non-reply paths do not weaken this either: a disconnected or no-op bind settles inline, inside the caller's own frame (which `Bridge::detail::parkIfInFrame` exists to handle), and `cancelPending` settles the remainder from `~Bridge` itself, which is not a *concurrent* destructor. A -future backend that replied on its own transport thread reopens morph#486 for +backend that replies on its own transport thread reopens the use-after-free for a `Bridge` constructed without a `bridgeExec`, and does not for one constructed -with a suitable one. That split is the whole of what morph#588 claims: the -guarantee this surface makes structural is a guarantee about *backends*, and -for `Bridge`-mediated calls the delivery thread is now the embedder's choice -rather than the backend's — a contract one embedder can satisfy, instead of one +with a suitable one. That split is the whole of the claim: the guarantee this +surface makes structural is a guarantee about *backends*, and for +`Bridge`-mediated calls the delivery thread is the embedder's choice rather +than the backend's — a contract one embedder can satisfy, instead of one every backend author must remember. `tests/test_backend_registration_surface.cpp` pins this: the backend settles @@ -414,11 +404,10 @@ auto local = std::make_shared(pool); morph::backend::SynchronousBackendAdapter adapter{local, pool}; ``` -It is what makes morph#522's steps 2–5 migrations rather than rewrites: every -backend that stays blocking — `LocalBackend`, `SimulatedRemoteBackend`, and -eleven test doubles — reaches the new surface without being edited. Two -backends deliberately do not use it, for the same reason in two different -shapes: `QtWebSocketBackend` (morph#568) and `SocketBackend` (morph#569) both +It is what lets a backend that stays blocking — `LocalBackend`, +`SimulatedRemoteBackend`, and eleven test doubles — reach this surface without +being edited at all. Two backends deliberately do not use it, for the same +reason in two different shapes: `QtWebSocketBackend` and `SocketBackend` both have a genuinely non-blocking path of their own, and wrapping either would reintroduce the blocking this exists to route around. `SocketBackend`'s case is set out under [The structural registration surface, @@ -428,7 +417,7 @@ natively](#the-structural-registration-surface-natively). What changes is which thread pays: the blocking call runs on the adapter's executor, so the caller returns immediately with an unresolved `Completion`. A single-threaded WASM main thread has no such executor to offer, which is - why morph#568 puts `QtWebSocketBackend` on the surface natively instead. + why `QtWebSocketBackend` implements the surface natively instead. - **It answers `bindWaitPolicy()` itself rather than forwarding it**, with `kCallerMustNotBlock`. `bindModel`/`promoteModel` are the two verbs it reshapes, so the policy describing them describes the adapter and not what it @@ -441,12 +430,12 @@ natively](#the-structural-registration-surface-natively). nothing would be a `bindModel` that blocks on some configurations and not others — contract by configuration, which is what is being removed. - **It cancels its own pending completions rather than only the wrapped - backend's** (morph#619). `bindModel`/`promoteModel` settle from a task on - `_control`, holding a promise the wrapped backend never sees, so the - one-line `_inner->cancelPending(exc)` this verb used to be reached none of - them: a bind cancelled by `~Bridge` or by `switchBackend` went on to resolve - **successfully** afterwards, against `IBackend::cancelPending`'s "after this - call, any later `setValue`/`setException` on those states is a no-op". The + backend's**. `bindModel`/`promoteModel` settle from a task on + `_control`, holding a promise the wrapped backend never sees, so a plain + `_inner->cancelPending(exc)` reaches none of them: a bind cancelled by + `~Bridge` or by `switchBackend` would go on to resolve **successfully** + afterwards, against `IBackend::cancelPending`'s "after this call, any later + `setValue`/`setException` on those states is a no-op". The adapter therefore keeps a `weak_ptr` to each dispatched promise and rejects the live ones first, on the same snapshot-then-deliver shape and the same amortised compaction as @@ -459,11 +448,11 @@ natively](#the-structural-registration-surface-natively). `LocalBackend::cancelPending` has always had. And a task already *inside* `op()` cannot be recalled: the adapter has no way to interrupt a blocking verb it does not implement. -- **It stops a control call the strand has not started yet** (morph#636). +- **It stops a control call the strand has not started yet.** Settling the promise is only half of the cancellation, because it says - nothing about the *work* behind it: a task still queued on `_control` used to - reach the head of the strand after `cancelPending` returned and make its - blocking control call anyway — an actual `registerModelWithContext` / + nothing about the *work* behind it: a task still queued on `_control` would + otherwise reach the head of the strand after `cancelPending` returned and + make its blocking control call anyway — an actual `registerModelWithContext` / `registerModelShared` / `attachModel` on the wrapped backend, whose `resolve` then found the state already rejected and did nothing. The caller was told the bind was cancelled while the registration went through, leaving a live @@ -492,12 +481,11 @@ natively](#the-structural-registration-surface-natively). `registerModelShared`/`registerModelWithContext`, which the adapter also forwards unchanged. - morph#615 changed the second half of that: the handler now calls - `bindModel`, so a wrapped backend's reconnect control calls *do* reach the - adapter's strand. What the proviso still rules out is the first half — the - handler itself is invoked by the wrapped backend, on whatever thread that - backend chooses, and the adapter does not move it. See morph#569's answer - under [`SocketBackend`](#socketbackend--socketserver--raw-socket-websocket-transport). + The handler calls `bindModel`, so a wrapped backend's reconnect control calls + *do* reach the adapter's strand. What the proviso rules out is the other + half — the handler itself is invoked by the wrapped backend, on whatever + thread that backend chooses, and the adapter does not move it. See + [`SocketBackend`](#socketbackend--socketserver--raw-socket-websocket-transport). ### Backends with a genuinely non-blocking path @@ -508,8 +496,8 @@ inside the dispatch call and settling it a second later from a transport thread are the same code at the call site, because delivery goes through `cbExec` either way. -Two backends in the tree take that route: `QtWebSocketBackend` (morph#568) and -`SocketBackend` (morph#569). `SocketBackend`'s case — why it is native rather +Two backends in the tree take that route: `QtWebSocketBackend` and +`SocketBackend`. `SocketBackend`'s case — why it is native rather than wrapped, and what that does to its reconnect-handler hazard — is set out under [The structural registration surface, natively](#the-structural-registration-surface-natively). @@ -531,11 +519,11 @@ non-empty `primary` with a zero `current` is a shared `register`; with a live one it is an `attach`. The two native implementations differ in transport and in gating, not in what a `BindRequest` shape means. -**They do differ in gating, and after morph#568 that difference is -observable.** `SocketBackend::bindModel` is unconditionally non-blocking; +**They do differ in gating, and that difference is observable.** +`SocketBackend::bindModel` is unconditionally non-blocking; `QtWebSocketBackend::bindModel` falls back to the blocking default unless -`asyncRegistrationEnabled` is set. Since morph#568 puts `Bridge` on this -surface, the gate now decides whether `Bridge::registerHandler` returns a +`asyncRegistrationEnabled` is set. Because `Bridge` dispatches through this +surface, the gate decides whether `Bridge::registerHandler` returns a *bound* handler: a blocking `bindModel` settles inside the dispatch frame, so the handler is bound on return, and a non-blocking one does not, so it is not. See [What a natively non-blocking backend does to @@ -544,7 +532,7 @@ below — that is a property of the surface, not of either transport. Two things that were four-way duplicated collapsed with the verbs. There is one send path (`sendControl`), so the "encode before recording the pending entry" -invariant and the `env.session` stamp of morph#495 each exist once rather than +invariant and the `env.session` stamp each exist once rather than four times; and there is one pending map, because `register`, shared `register`, `attach` and `assign` replies were always matched identically. (`SocketBackend` reached the same conclusion independently and calls its own @@ -569,12 +557,8 @@ thread, and a WASM main thread has no other thread to move it to. ### What a natively non-blocking backend does to `registerHandler` `Bridge::registerHandler` is synchronous and returns a `BridgeHandler`. What it -cannot do is make the *backend* synchronous. Before morph#568 the fallback under -the removed non-blocking twin was the blocking -`registerModelWithContext`, so on every backend except a `QtWebSocketBackend` -with `asyncRegistrationEnabled` set, the handler was bound by the time the -constructor returned. Since morph#568 there is only `bindModel`, so the rule is -stated once, structurally: +cannot do is make the *backend* synchronous. There is one acquire verb, so the +rule is stated once, structurally: > **`registerHandler` returns a bound handler unless the backend says the > caller must not wait.** A backend that has not overridden `bindModel` gets @@ -584,22 +568,19 @@ stated once, structurally: > still bound — unless that backend answers > `BindWait::kCallerMustNotBlock`, in which case the handler is returned > **unbound** and the caller must gate on `Bridge::whenBound()` (or on the -> `onDone` of the async entry points) before issuing a call, exactly as the -> `*Async` path already required. - -The wait is morph#593's correction to the rule as morph#568 first wrote it. The -rule then read "bound exactly when `bindModel` settles inside the call", which -made *how the backend is implemented* decide what a synchronous, public entry -point returns — and `SocketBackend` (morph#569) changed its implementation -without any intent to change that. See +> `onDone` of the async entry points) before issuing a call. + +The wait is what keeps the rule about the *policy* rather than about the +implementation. Stated as "bound exactly when `bindModel` settles inside the +call", it would make *how a backend is implemented* decide what a synchronous, +public entry point returns, so a backend gaining a native non-blocking path +would silently change `registerHandler`'s contract. See [Waiting for a bind — `bindWaitPolicy`](#waiting-for-a-bind--bindwaitpolicy). `executeVia` fails fast with `"handler not bound"` for a call issued before the -reply arrives; it does not queue. That is unchanged — it is the same failure the -removed `*Async` path produced, now reachable through one surface instead of -two. +reply arrives; it does not queue. -Consequences, as of morph#568 and morph#593: +Consequences: | Backend | `bindModel` | `bindWaitPolicy()` | `registerHandler` returns | |---|---|---|---| @@ -607,32 +588,30 @@ Consequences, as of morph#568 and morph#593: | A backend wrapped in `SynchronousBackendAdapter` | non-blocking, on the adapter's executor | `kCallerMustNotBlock` | **unbound** | | `QtWebSocketBackend`, `asyncRegistrationEnabled` unset | default (blocking verb) | `kCallerMayBlock` | bound | | `QtWebSocketBackend`, `asyncRegistrationEnabled` set | native, non-blocking | `kCallerMustNotBlock` | **unbound** (the WASM case, which is the whole point) | -| `SocketBackend` (morph#569) | native, non-blocking | `kCallerMayBlock` (default) | bound, after waiting for the I/O thread's reply | +| `SocketBackend` | native, non-blocking | `kCallerMayBlock` (default) | bound, after waiting for the I/O thread's reply | ### Waiting for a bind — `bindWaitPolicy` `Bridge::registerHandlerImpl` has exactly one call site for acquiring a model, -and after morph#568 and morph#569 two shipped backends needed opposite -behaviour from it: +and two shipped backends need opposite behaviour from it: | Backend | What `bindModel` does | What the call site must do | Why | |---|---|---|---| | `SocketBackend` | returns an unsettled `Completion`; the I/O thread settles it | **wait** | its callers construct a `BridgeHandler` and use it on the next line; `executeVia` fails fast on `currentId == 0` | | `QtWebSocketBackend`, `asyncRegistrationEnabled` set | returns an unsettled `Completion` | **not wait** | the reply is delivered by the Qt event loop of the calling thread, so a wait is a deadlock — on WASM, a page abort | -From the `Completion` alone the two are indistinguishable, and the surface -morph#567 introduced had removed the only signal that told them apart (the -`*Async` verbs' `bool` return). Measured, not argued: on morph#568's head, six -`tests/net/` cases failed with `"handler not bound"`, and forcing the *other* -answer at that call site instead hung five `tests/qt/` cases on the nested -`QEventLoop`. Neither fixed setting of the call site is correct. +From the `Completion` alone the two are indistinguishable, so the call site +needs a signal. Measured, not argued: hard-coding "do not wait" fails six +`tests/net/` cases with `"handler not bound"`, and hard-coding "wait" hangs +five `tests/qt/` cases on the nested `QEventLoop`. Neither fixed setting of the +call site is correct. -Since morph#615 the same question is asked at all three sites that acquire an -instance for a binding — `registerHandlerImpl`, `switchBackend`'s phase 1 and -the reconnect handler — because all three used to block a thread that a -`kCallerMustNotBlock` backend needs back before its reply can arrive. The -first was the only synchronous *entry point*; the other two are worse, because -the reconnect handler runs on the backend's own transport thread. +The same question is asked at all three sites that acquire an instance for a +binding — `registerHandlerImpl`, `switchBackend`'s phase 1 and the reconnect +handler — because each of them would otherwise block a thread that a +`kCallerMustNotBlock` backend needs back before its reply can arrive. The first +is the only synchronous *entry point*; the other two are worse, because the +reconnect handler runs on the backend's own transport thread. `IBackend::bindWaitPolicy()` restores exactly one bit: @@ -646,9 +625,9 @@ the reconnect handler runs on the backend's own transport thread. which exists to move the blocking elsewhere, and would deadlock if the caller happened to be running on its executor). -It is deliberately not the `bool` morph#567 removed. That `bool` chose *which -verb to call*, so every call site carried two paths and a backend could be -half-migrated; this one chooses nothing. There is still exactly one verb, called +It is deliberately not a `bool` choosing *which verb to call*: that shape gives +every call site two paths and lets a backend be half-adopted. This one chooses +nothing. There is still exactly one verb, called unconditionally, and exactly one continuation — the only question is whether the thread that registered that continuation is allowed to stop and wait for it. Only a frame that would otherwise stop and wait asks: `registerHandlerImpl` @@ -663,103 +642,67 @@ non-determinism the synchronous contract exists to exclude; a backend that breaks its own settle-exactly-once contract therefore hangs here rather than silently handing back an unbound handler. -`SocketBackend`'s row above is the one morph#568 changed and morph#569 did not: -morph#569 landed while `Bridge` still called the legacy verbs, so its native -`bindModel` had no caller. morph#593 puts that row back to "bound" rather than -asking every existing non-Qt embedder to start gating on `whenBound()` — -`docs/spec/core/bridge.md`'s `whenBound()` section remains the right place for a -caller that wants to gate anyway, and is required for the two +`SocketBackend`'s row is the reason the policy exists rather than a rule keyed +on "is this backend native": it is natively non-blocking *and* answers +`kCallerMayBlock`, so it returns a bound handler like every synchronous backend +does. `docs/spec/core/bridge.md`'s `whenBound()` section is the right place for +a caller that wants to gate anyway, and is required for the two `kCallerMustNotBlock` rows. -### Migration status - -| Step | What it does | State | -|---|---|---| -| morph#567 | Adds `bindModel`/`promoteModel` and `SynchronousBackendAdapter`. Nothing else changes. | Landed | -| morph#568 | `QtWebSocketBackend` implements the surface natively and drops all four `*Async` overrides; `Bridge`'s four dispatch sites fall back to it instead of to a synchronous verb. | Landed | -| morph#569 | `SocketBackend` implements the surface natively, keeping every legacy verb on `sendSync`. | Landed | -| morph#593 | Adds `IBackend::bindWaitPolicy()`, the one signal morph#567's surface left the call site without. Fixes the `"handler not bound"` regression morph#568 caused in `SocketBackend`. | Landed | -| morph#570 | The example GUIs and the WASM spike. | Landed | -| morph#615 | `Bridge::switchBackend`'s phase 1 and `Bridge::installReconnectHandler`'s handler onto `bindModel`, both consulting `bindWaitPolicy()`; `switchBackend`'s rollback keys on a rejected `Completion`. Also settles who owned the reconnect half: this table used to assign it to morph#570, whose own body scopes itself to `examples/` and never mentions `bridge.hpp`. | Landed | -| morph#571 | Removes the four `*Async` verbs from `IBackend` and from `SynchronousBackendAdapter`, and drops `Bridge`'s four offer-the-twin-first branches. `LocalBackend`, `SimulatedRemoteBackend` and the test doubles in `tests/test_switch_backend.cpp`, `tests/test_bridge_lifetime.cpp` and `tests/test_client_execute_deadline.cpp` needed no migration: none overrode a twin, so all reach `bindModel`'s default unchanged. | Landed | - -Every existing implementor still compiles unchanged, and the default -`bindModel`/`promoteModel` implementations route to exactly the synchronous verb -each request shape names — so a backend that has overridden nothing behaves -identically to how it did before the surface existed. What changed with -morph#568 was the *caller*: every `Bridge` dispatch site took the shape - -```cpp -bool const started = backend->Async(..., onOk, onErr); // removed by morph#571 -if (!started) { - auto completion = backend->bindModel(request, exec::detail::inlineExecutor()); - completion.then(onOk).onError(onErr); -} -``` - -so the path count did not grow: the structural call replaced the synchronous -fallback that used to sit there. morph#571 deleted the first branch, leaving -one. No backend in the tree had overridden a `*Async` verb since morph#568, so -the second branch was already the one that always ran; the doubles in -`tests/test_async_registration.cpp` that did override them now override -`bindModel`/`promoteModel` instead and answer -`BindWait::kCallerMustNotBlock`, which is what reproduces "dispatch and return -without waiting" — the observable behaviour the `true` return used to produce. - -One thing the removal did lose, named rather than left to be found: a `bool` -twin handed `Bridge` two raw `std::function`s, so a backend that violated the -one-callback contract by firing twice reached `detail::parkIfInFrame`'s own -double-claim guard. A `Completion` cannot be settled twice — `CompletionState` -drops the second settle before any `Bridge` code sees it — so -`tests/test_async_registration.cpp`'s `DoubleFiringBackend` now pins the -observable contract ("exactly one `onDone`") while that guard inside -`parkIfInFrame` is no longer reachable from a backend at all. - -morph#648 settled what to do about the now-unreachable arm, and corrected the -reason recorded here: this section used to say the guard was kept "because -`parkIfInFrame` is also called from the dispatching frame", which is false — -the dispatching frame calls `claimHandoff`/`awaitHandoff`, and all eight -`parkIfInFrame` call sites are completion callbacks. That the arm is -unreachable is *measured*, not read: replacing its `return true` with an +### The default implementations, and what they cost a backend + +A backend that overrides nothing gets `IBackend`'s `bindModel`/`promoteModel`, +which route to exactly the synchronous verb each request shape names — so +implementing this surface is optional for a backend that has no non-blocking +path, and `LocalBackend`, `SimulatedRemoteBackend` and every test double in the +tree take that route unchanged. A double that wants "dispatch and return +without waiting" overrides `bindModel`/`promoteModel` and answers +`BindWait::kCallerMustNotBlock`; `tests/test_async_registration.cpp` has +several. + +One consequence of settling a `Completion` rather than invoking raw callbacks, +named rather than left to be found: a backend that violates the one-callback +contract by firing twice cannot be observed doing it from `Bridge`. +`CompletionState` drops the second settle before any `Bridge` code sees it, so +`tests/test_async_registration.cpp`'s `DoubleFiringBackend` pins the observable +contract ("exactly one `onDone`") and `detail::parkIfInFrame`'s own +double-claim guard is not reachable from a backend at all. + +That guard is kept, and why is worth stating because the obvious reason is the +wrong one: it is **not** that `parkIfInFrame` is also called from the +dispatching frame — the dispatching frame calls `claimHandoff`/`awaitHandoff`, +and all eight `parkIfInFrame` call sites are completion callbacks. That the arm +is unreachable is *measured*, not read: replacing its `return true` with an `abort()` and running `morph_tests` (1556 cases) and `morph_net_tests` (191) -fires it zero times. It is kept anyway, for a different reason than the one -that was written down — the invariant that makes it dead is a property of every -current *caller*, not of the function, so a ninth site that does not park a -single `Completion`'s outcome would resurrect it — and it is now pinned by a -test that calls `parkIfInFrame` directly, twice on one handoff, rather than -left as an arm whose deletion nothing would detect. The neighbouring -`try`/`catch (...)` around the dispatch was decided separately and left alone: -it is reachable by any out-of-tree `IBackend` override that throws out of +fires it zero times. It is kept because the invariant that makes it dead is a +property of every current *caller*, not of the function, so a ninth site that +does not park a single `Completion`'s outcome would resurrect it — and it is +pinned by a test that calls `parkIfInFrame` directly, twice on one handoff, +rather than left as an arm whose deletion nothing would detect. The +neighbouring `try`/`catch (...)` around the dispatch is a separate case and is +live: it is reachable by any out-of-tree `IBackend` override that throws out of `bindModel`, `IBackend` is a public extension point, and -`ThrowingDispatchBackend` already exercises it — so it is covered defensive -code, not dead code. - -`Bridge::installReconnectHandler` and `Bridge::switchBackend`'s phase 1 were -the two dispatch sites morph#568 did **not** move: both still called the -blocking `registerModelShared`/`registerModelWithContext`, so a backend that -had just been given a way to say `kCallerMustNotBlock` was blocked at both of -them anyway. morph#615 moved them, and its shape is the one every other site -already had — dispatch, park an inline reply in an `AsyncDispatchHandoff`, -then `awaitHandoff` or `claimHandoff` according to the policy. What made it a -change of its own rather than part of morph#568 is what the two sites do -around the call: `switchBackend` stages for an all-or-nothing commit whose -rollback used to key on a thrown exception, and the reconnect handler runs on -the transport thread holding both bridge mutexes. Both are described in -[bridge.md](bridge.md). +`ThrowingDispatchBackend` exercises it — covered defensive code, not dead code. + +All five `Bridge` dispatch sites have the same shape — dispatch, park an inline +reply in an `AsyncDispatchHandoff`, then `awaitHandoff` or `claimHandoff` +according to the policy. `installReconnectHandler` and `switchBackend`'s phase 1 +are the two that do most around the call: `switchBackend` stages for an +all-or-nothing commit whose rollback keys on a rejected `Completion`, and the +reconnect handler runs on the transport thread holding both bridge mutexes. +Both are described in [bridge.md](bridge.md). `SocketBackend`'s reconnect-handler deadlock hazard is narrowed rather than -closed by that: the control call it makes is no longer the blocking verb, but -the handler is still *invoked* by the backend on whatever thread the backend -chooses, which is the half morph#569 owns. - -The executor those call sites name is **`exec::detail::inlineExecutor()`**, which -runs the continuation on the thread that settled it. That is deliberately the -*old* delivery thread, so neither morph#568 nor morph#571 changes observable -*threading*: `Bridge` owns no event loop and has no thread of its own to name. -morph#588 left that argument alone — an inline settle must still be delivered -inline, or `registerHandler()` stops being synchronous — and gave `Bridge` an -optional executor for the replies that arrive *after* the dispatch frame -instead. See +closed by that: the control call it makes is not the blocking verb, but the +handler is still *invoked* by the backend on whatever thread the backend +chooses, which is `SocketBackend`'s own half of the problem. + +The executor those call sites name is **`exec::detail::inlineExecutor()`**, +which runs the continuation on the thread that settled it — deliberately, since +`Bridge` owns no event loop and has no thread of its own to name, and an inline +settle must be delivered inline or `registerHandler()` stops being synchronous. +The optional `bridgeExec` covers the replies that arrive *after* the dispatch +frame instead. See [How the threading contract becomes structural](#how-the-threading-contract-becomes-structural) and [bridge.md](bridge.md), "The bridge's own executor". @@ -840,9 +783,8 @@ else consults it, and nothing unlinks from it when a completion settles: an entr simply becomes a dead `weak_ptr` that `cancelPending`'s `weak.lock()` skips. Dead entries therefore have to be reclaimed by a sweep, and the question is only -how often. Sweeping on **every** `execute`, as `trackPending` did before -morph#528, makes admitting one call cost one atomic `weak_ptr::expired()` load -per entry already in the list, under the mutex, before any work starts — so a +how often. Sweeping on **every** `execute` makes admitting one call cost one +atomic `weak_ptr::expired()` load per entry already in the list, under the mutex, before any work starts — so a burst of *n* costs O(n²). Measured against one parked model on an 8-core Linux box (clang 22.1.8, `-O2`), timing only the `execute()` calls themselves: @@ -1100,9 +1042,9 @@ because `awaitTurn` waits with no deadline. `execute_order_gate.hpp`; `remote.hpp` keeps a `using` alias so call sites read unqualified) from the moment a ticket is issued: `dispatchMessage` adopts the ticket into one for the whole of its own frame, `dispatchExecute` included. -`handleImpl` no longer holds a separate guard across the pool post — morph#519 -folded the take and the post into one `takeAndPost` call, so there is no window -between them for a guard to cover. The guard releases on destruction, so *every* exit +`handleImpl` holds no separate guard across the pool post: the take and the post +are one `takeAndPost` call, so there is no window between them for a guard to +cover. The guard releases on destruction, so *every* exit path is covered — each explicit `return`, every exception, and any branch a later change adds. Two members opt out deliberately: @@ -1111,16 +1053,15 @@ later change adds. Two members opt out deliberately: paths free the gate *before* writing their reply instead of at end of scope; and - `disarm()`, which hands ownership on rather than releasing. It has **no - production caller** since morph#519 replaced `handleImpl`'s take-then-post - pair with `takeAndPost`; it is retained as part of the guard's API and - exercised only by tests. Calling it without taking ownership elsewhere - discards the only handle to an outstanding ticket, which can then never be - released — the #348/#351 failure mode. + production caller** — `handleImpl` uses `takeAndPost` — and is retained as + part of the guard's API, exercised only by tests. Calling it without taking + ownership elsewhere discards the only handle to an outstanding ticket, which + can then never be released. -It was a per-call-site convention until it had been missed twice — by the -shutdown gate (issue #348) and by every exception unwinding out of -`dispatchExecute` (issue #351, below). Both are pinned by regression tests, and -neither could recur through a hand-written release being forgotten again. +A per-call-site convention is not enough here, because it is missable in two +places that do not look like exits: the shutdown gate, and every exception +unwinding out of `dispatchExecute` (below). Both are pinned by regression +tests, and neither can recur through a hand-written release being forgotten. **Bookkeeping.** A gate is `{nextTicket, nextToRun, releasedOutOfOrder, condition_variable}`, held in a @@ -1140,7 +1081,7 @@ from parking a worker forever. **One enqueue mutex, gate-wide.** `takeAndPost` holds an enqueue mutex from before it mints a ticket until after `postFn` returns, which is what makes the -take and the enqueue atomic (morph#519). That mutex is **one per gate, not one +take and the enqueue atomic. That mutex is **one per gate, not one per model**, and is acquired *before* the bookkeeping mutex, never the reverse. `postFn` is opaque: on a `ThreadPoolExecutor` it only enqueues, but on a @@ -1174,9 +1115,8 @@ whenever a rejection got there first. That earlier ticket's waiter then held a predicate that could never become true again, with the same three costs listed for the shutdown gate below: a caller that never receives a reply, a pool worker blocked for the process's remaining lifetime, and `drainedWithin()` -unable to succeed. It is why the bug class closed by issues #348 and #351 -returned a third time as issue #449, reproducing under `morph::net` in -particular — dropping a connection reclaims that connection's models, so the +unable to succeed. It is the third distinct way into this same failure, and it +reproduces under `morph::net` in particular — dropping a connection reclaims that connection's models, so the executes still in flight for one model split into some that find it and some that reject with `"model not found"`, which manufactures precisely this interleaving. Making the *release* unmissable (below) was necessary and not @@ -1231,7 +1171,7 @@ throwing, and `missingRequiredFields` parses the payload under ticket-taking site and the release. The cost was identical to the shutdown gate's, item for item — stranded caller, blocked pool worker, `drainedWithin()` unable to succeed — and it is why the release is now owned by -`ExecuteTicketGuard` rather than written out at each exit (issue #351). +`ExecuteTicketGuard` rather than written out at each exit. The release rule stated above therefore holds without exception, which is what makes the rest of this section true. @@ -1254,7 +1194,7 @@ rather than raced; its throwing-hook case reuses that same wrapping executor and arms an authorizer only *after* the later request has parked in `awaitTurn`, so exactly one request — the held, earlier one — throws, from `authorize`, `authenticate` or `authorizeInstance` in turn. Its -out-of-order-release case (issue #449) extends the same wrapping executor to +out-of-order-release case extends the same wrapping executor to hold three posts at once, so all three tickets exist before any of them runs, and then releases the *middle* one first: a rejection skipping over ticket 0 while ticket 2 is still outstanding, which is the interleaving the two-ticket @@ -1567,7 +1507,7 @@ by `_pendingMtx`, because `cancelPending` can be called from `Bridge` / - `registerModelWithContext` — the same `register` round trip, carrying `contextKey` on the envelope (`wire::makeRegister(typeId, contextKey)`). - Overridden since morph#594; before that it was `IBackend`'s default, which + Overridden here rather than left at `IBackend`'s default, which drops the key. That is not a missing field: `RemoteServer::attachLogIfConfigured` returns **before** consulting its `LogProvider` when the envelope's `contextKey` is empty, so a privately @@ -1645,7 +1585,7 @@ frame and routes it by `callId`: than hanging. `deregister` is deliberately **not** on that list — it is fire-and-forget, nobody parks for its reply, and sending it with `callId == 0` would let its stray `ok` resume an unrelated parked control call (issue - #65); it therefore takes a non-zero `callId` and its reply is recognised + above); it therefore takes a non-zero `callId` and its reply is recognised and discarded via `_pendingDeregisters`. Because `execute` replies are matched on `callId`, concurrent in-flight execute @@ -1891,7 +1831,7 @@ nested `QEventLoop` that keeps pumping the socket. `SocketBackend` overrides `bindModel`/`promoteModel` itself rather than being wrapped in [`SynchronousBackendAdapter`](#synchronousbackendadapter--a-blocking-backend-on-the-new-surface). It had overridden none of the four `*Async` verbs, so the wrapper was the -expected route; three findings decided against it (morph#569). +expected route; three findings decide against it. 1. **The transport already has the machinery.** The I/O thread demultiplexes replies by `callId` for `execute`, and `RemoteServer` echoes `callId` on @@ -1909,11 +1849,11 @@ expected route; three findings decided against it (morph#569). strand, and this backend is explicitly documented as safe to drive from several threads at once. The native path takes no token at all. 3. **The adapter's reconnect property does not apply here** — see the answer - below, which is the question morph#569 was opened to settle. + below. What does **not** change: `registerModel`, `registerModelShared`, `attachModel` and `assignPrimary` still use `sendSync` with `callId == 0`, so every caller -morph#570/#571 has yet to migrate behaves exactly as before, and nothing on the +has not been moved onto the new surface behaves exactly as before, and nothing on the wire changes for them. `cancelPending` now sweeps both tables, so a disconnect rejects an in-flight bind with `DisconnectedError` instead of stranding it — the asynchronous counterpart of `sendSync` waking on `!_connected`. @@ -1939,11 +1879,11 @@ thread that would satisfy it. Stated precisely, in three parts: handler thread is still load-bearing.** The blocking verbs still park on `_syncCv`, and `Bridge`'s reconnect handler still calls them. A hazard-free route now exists; the hazardous one has not been removed or moved. Only when - morph#570 puts `Bridge::installReconnectHandler` on `bindModel` does the + `Bridge::installReconnectHandler` goes through `bindModel` does the hazard become unreachable from the reconnect path, and only then is dropping the handler thread a question worth asking. -None of this touches morph#486. The surface adds no lock and knows nothing +None of this touches the use-after-free window. The surface adds no lock and knows nothing about a caller's teardown; it moves the choice of delivery thread from the implementor to the caller, exactly as [How the threading contract becomes structural](#how-the-threading-contract-becomes-structural) @@ -2051,14 +1991,14 @@ indefinitely, with no timeout. `shutdownBoth()` is documented safe to call from any thread for exactly this purpose, on a **connected** socket. **The accept loop owns its own wakeup, and does not borrow the kernel's** -(morph#437). The accept thread never parks in `accept(2)`. `listen()` sets +The accept thread never parks in `accept(2)`. `listen()` sets `O_NONBLOCK` on the listening socket and creates a self-pipe; the loop waits in a single `poll()` over the listening fd and the pipe's read end, and takes a ready connection with `TcpSocket::tryAccept()`, which answers `std::nullopt` rather than parking when a readiness report has gone stale. `close()` writes one byte to the pipe before `join()`, which is what ends the loop. -**The listener's non-blocking mode stops at the listener** (morph#478). +**The listener's non-blocking mode stops at the listener.** `TcpSocket`'s fd-adopting constructor clears `O_NONBLOCK` on every descriptor it takes ownership of, so a connection from `accept()` or `tryAccept()` is always blocking, whatever mode the listener it came from is in. That is a correctness @@ -2152,7 +2092,7 @@ are: begins; there is no cancel. (The `_shuttingDown` disjunct that used to sit in `waitForConnected`'s predicate looked like an escape hatch for exactly this and was not one — the destructor never notifies `_connectCv`, so it could not - reliably release anybody — and was removed as part of morph#455.) + reliably release anybody.) ## Failure modes @@ -2317,8 +2257,8 @@ inside the class calls `close()` — no thread it joins can be waiting on it. | `bindModel(request, cbExec)` | Posts `inner->bindModelBlocking(request)` onto the control strand; settles the returned `Completion` on `cbExec`. Never blocks the caller. | | `bindWaitPolicy()` | `BindWait::kCallerMustNotBlock`, always. Not forwarded: it describes the two verbs the adapter reshapes. | | `promoteModel(request, cbExec)` | Posts `inner->assignPrimary(...)` onto the control strand; resolves with `request.mid`. | -| `cancelPending(exc)` | Sets each still-unsettled `bindModel`/`promoteModel` record's `cancelled` flag and rejects its promise with `exc`, **then** forwards to `inner`. Not a plain forward: those promises are settled from `_control` tasks the wrapped backend has never heard of (morph#619). The flag is what stops a task still *queued* on `_control` from making its blocking control call after the caller was told the bind was cancelled (morph#636); a task already inside that call is unaffected. | -| every other `IBackend` verb | Forwarded to `inner` unchanged. Since morph#571 those are the synchronous verbs only: the one verb that could carry a non-blocking path is `bindModel`, which this adapter reshapes. | +| `cancelPending(exc)` | Sets each still-unsettled `bindModel`/`promoteModel` record's `cancelled` flag and rejects its promise with `exc`, **then** forwards to `inner`. Not a plain forward: those promises are settled from `_control` tasks the wrapped backend has never heard of. The flag is what stops a task still *queued* on `_control` from making its blocking control call after the caller was told the bind was cancelled; a task already inside that call is unaffected. | +| every other `IBackend` verb | Forwarded to `inner` unchanged — the synchronous verbs only: the one verb that could carry a non-blocking path is `bindModel`, which this adapter reshapes. | ### Error types @@ -2389,7 +2329,7 @@ inside the class calls `close()` — no thread it joins can be waiting on it. | Method | Notes | |---|---| | `QtWebSocketBackend(serverUrl, dispatcher = defaultDispatcher(), registry = defaultRegistry(), tls = nullopt, cfg = Config{})` | Opens the socket to `serverUrl` in the constructor. `dispatcher`/`registry` params are accepted but unused (models live on the server). `tls` non-null → `wss://`. `tls` is not declared at all when Qt is built with `QT_NO_SSL` (see above). | -| `QtWebSocketBackend(serverUrl, tls, cfg = Config{})` | Overload that skips the unused `dispatcher`/`registry` pair (issue #55): a caller who only needs `tls`/`cfg` reaches them directly, without naming `morph::model::detail::defaultDispatcher()`/`defaultRegistry()` explicitly. Delegates to the main constructor with both defaulted. Not declared on a `QT_NO_SSL` build (no `tls` parameter to distinguish it from the `(serverUrl, cfg)` overload below). | +| `QtWebSocketBackend(serverUrl, tls, cfg = Config{})` | Overload that skips the unused `dispatcher`/`registry` pair: a caller who only needs `tls`/`cfg` reaches them directly, without naming `morph::model::detail::defaultDispatcher()`/`defaultRegistry()` explicitly. Delegates to the main constructor with both defaulted. Not declared on a `QT_NO_SSL` build (no `tls` parameter to distinguish it from the `(serverUrl, cfg)` overload below). | | `QtWebSocketBackend(serverUrl, cfg)` | Overload that skips `dispatcher`/`registry` and `tls` together — the common case for a caller that only wants to set a `Config` field (e.g. `asyncRegistrationEnabled`) over a plaintext `ws://` connection. Delegates to the main constructor with `dispatcher`/`registry` defaulted and (on an SSL-enabled build) `tls = std::nullopt`. | | `bindModel(request, cbExec)` | Defers to `IBackend::bindModel` (blocking) unless `cfg.asyncRegistrationEnabled` is `true`. Otherwise builds the envelope `request`'s shape names — `register`, shared `register`, or `attach` — assigns a fresh `callId` (the same counter `execute` uses), records the promise in `_pendingRegistrations[callId]` and sends. The `Completion` settles later from `onTextMessage` (or from `cancelPending` on a disconnect). A private bind on an unconnected socket is queued in `_queuedRegistrations` instead; a keyed one rejects with `"disconnected"`. | | `bindWaitPolicy()` | `kCallerMustNotBlock` exactly when `cfg.asyncRegistrationEnabled` is set — that is when completions are settled by `onTextMessage`, a Qt slot delivered by the calling thread's own event loop, so a caller that blocked waiting for one would never see it arrive. `kCallerMayBlock` otherwise, where `bindModel` settles inside the call. | @@ -2397,8 +2337,8 @@ inside the class calls `close()` — no thread it joins can be waiting on it. | `waitForConnected(timeoutMs = 5000)` | Pumps the Qt loop until connected or timeout; returns `_connected`. | | `negotiateProtocolVersion()` | Opt-in: sends `hello` synchronously (same nested-`QEventLoop` path as `registerModel`), classifies the reply via `wire::interpretHelloReply`. Throws on an explicit version rejection or a `sendSync` failure. | | `registerModel(typeId, factory)` | Forwards to `registerModelWithContext` with an empty key. | -| `registerModelWithContext(typeId, factory, contextKey)` | Synchronous via nested `QEventLoop`; `factory` ignored. Sends `register` carrying `contextKey`, so a privately registered instance is journalled (morph#594). Throws on `err` reply, and wraps a `sendSync` failure as `"register failed: "`. | -| `deregisterModel(mid)` | **Fire-and-forget** — sends only if connected, does not wait for the ack. Carries a non-zero `callId` from the same counter `execute` uses, recorded in `_pendingDeregisters` so `onTextMessage` recognises the unwanted reply and drops it rather than handing it to a parked `sendSync` (issue #65). | +| `registerModelWithContext(typeId, factory, contextKey)` | Synchronous via nested `QEventLoop`; `factory` ignored. Sends `register` carrying `contextKey`, so a privately registered instance is journalled. Throws on `err` reply, and wraps a `sendSync` failure as `"register failed: "`. | +| `deregisterModel(mid)` | **Fire-and-forget** — sends only if connected, does not wait for the ack. Carries a non-zero `callId` from the same counter `execute` uses, recorded in `_pendingDeregisters` so `onTextMessage` recognises the unwanted reply and drops it rather than handing it to a parked `sendSync`. | | `execute(mid, call, cbExec)` | Assigns a `callId`, sends `execute`, returns a `Completion`. Immediate `DisconnectedError` if not connected. | | `notifyBackendChanged()` | No-op. | | `cancelPending(exc)` | Drains `_pending`, `_pendingRegistrations` and `_queuedRegistrations` under `_pendingMtx`, then delivers `exc` to each — the exception itself, so a control call rejected by a dropped socket carries the same `DisconnectedError` an `execute` does. | @@ -2453,10 +2393,10 @@ not a behavior change to the existing loopback-only default. | `explicit SocketBackend(serverUrl, cfg = Config{})` | Parses `serverUrl` (`ws://` only — throws immediately on `wss://`) and starts the I/O thread, which connects asynchronously. | | `waitForConnected(timeout = 5000ms)` | Blocks the calling thread on a condition variable until connected or the timeout elapses; returns the current connected state. The backend must outlive the call — destroying it while a thread is parked here is undefined, and there is no cancel (see Lifetime & ownership). | | `registerModel(typeId, factory)` | Forwards to `registerModelWithContext` with an empty `contextKey`; `factory` ignored. | -| `registerModelWithContext(typeId, factory, contextKey)` | Synchronous via a parked condition variable; sends `register` carrying `contextKey`, so the server's `LogProvider` is consulted for a private registration exactly as it is for a shared one (morph#587). `factory` ignored. Throws on `err` reply or disconnect. Thread-safe, but only one such call may be in flight at a time. `registerModelShared` and `attachModel` degrade here when `primary` is empty, so their private paths carry the key too. | +| `registerModelWithContext(typeId, factory, contextKey)` | Synchronous via a parked condition variable; sends `register` carrying `contextKey`, so the server's `LogProvider` is consulted for a private registration exactly as it is for a shared one. `factory` ignored. Throws on `err` reply or disconnect. Thread-safe, but only one such call may be in flight at a time. `registerModelShared` and `attachModel` degrade here when `primary` is empty, so their private paths carry the key too. | | `bindModel(request, cbExec)` | Native override of the structural surface. Sends the envelope `request`'s shape names with a non-zero `callId` and returns immediately; every shape carries `request.contextKey`, the private one included; the I/O thread settles the `Completion` when the reply arrives, delivered on `cbExec`. Never enters `sendSync`, so it takes no synchronous-call token and any number may be in flight. Rejects with `DisconnectedError` when the socket is down or drops first, or with `std::runtime_error{" failed: "}`. | | `promoteModel(request, cbExec)` | The `assign` counterpart of `bindModel`, on the same path; resolves with `request.mid` echoed back. An empty `primary` or a zero `mid` resolves without sending, matching `assignPrimary`'s guards. | -| `deregisterModel(mid)` | **Fire-and-forget** — sends only if connected, does not wait for the ack. Carries a non-zero `callId` from the same counter `execute` uses so its unawaited `ok` cannot be handed to a parked synchronous control call (issue #454; the `QtWebSocketBackend` precedent is issue #65). Needs no pending-id bookkeeping of its own: `dispatchIncomingEnvelope` already drops a non-zero `callId` that is absent from `_pending`. | +| `deregisterModel(mid)` | **Fire-and-forget** — sends only if connected, does not wait for the ack. Carries a non-zero `callId` from the same counter `execute` uses so its unawaited `ok` cannot be handed to a parked synchronous control call, as it is on `QtWebSocketBackend`. Needs no pending-id bookkeeping of its own: `dispatchIncomingEnvelope` already drops a non-zero `callId` that is absent from `_pending`. | | `execute(mid, call, cbExec)` | Assigns a `callId`, sends `execute`, returns a `Completion`. Immediate `DisconnectedError` if not connected. Thread-safe; supports concurrent in-flight calls from multiple threads. | | `notifyBackendChanged()` | No-op. | | `cancelPending(exc)` | Drains **both** pending maps — the `execute` calls and the `bindModel`/`promoteModel` control calls — and delivers `exc` to each state. | @@ -2473,9 +2413,9 @@ not a behavior change to the existing loopback-only default. | Method | Notes | |---|---| | `SocketServer(server, port = 0, cfg = Config{})` | Fronts `RemoteServer& server`. Does not start listening. | -| `listen()` | Binds `127.0.0.1:port`, makes the listening socket non-blocking, creates the accept loop's wakeup pipe, and spawns the accept thread; returns success. Fails closed (`false`, no thread) if the wakeup pipe cannot be created — an accept loop nothing can interrupt is worse than not listening (morph#437). | +| `listen()` | Binds `127.0.0.1:port`, makes the listening socket non-blocking, creates the accept loop's wakeup pipe, and spawns the accept thread; returns success. Fails closed (`false`, no thread) if the wakeup pipe cannot be created — an accept loop nothing can interrupt is worse than not listening. | | `port()` | Bound port (OS-assigned when constructed with `0`), or `0` before `listen()` succeeds. | -| `close()` | Stops accepting, shuts down and joins every client thread and the accept thread. Idempotent; also run by the destructor. Interrupts the accept loop by writing one byte to the wakeup pipe it polls (morph#437) — **not** by `shutdownBoth()` on the listening socket, which only worked because Linux kicks a parked `accept(2)` on shutdown and left macOS/BSD teardown hanging forever. Releases the listening descriptor after the join, so `port()` reads `0` afterwards. Serialized against itself by a dedicated mutex, so concurrent callers on a **live** object are safe and each returns only once teardown is complete (morph#451: the previous `_closing.exchange` guard let a second caller reach `_acceptThread.join()` while the first was inside it — two joins on one `std::thread`, which hangs forever on Linux/glibc and throws `std::system_error` on macOS/libc++). The wakeup write runs under that same mutex and at most once per `listen()`/`close()` cycle. Racing `close()` against the *destructor* remains out of contract, as for any member call. | +| `close()` | Stops accepting, shuts down and joins every client thread and the accept thread. Idempotent; also run by the destructor. Interrupts the accept loop by writing one byte to the wakeup pipe it polls — **not** by `shutdownBoth()` on the listening socket, which works only because Linux kicks a parked `accept(2)` on shutdown and leaves macOS/BSD teardown hanging forever. Releases the listening descriptor after the join, so `port()` reads `0` afterwards. Serialized against itself by a dedicated mutex, so concurrent callers on a **live** object are safe and each returns only once teardown is complete. A `_closing.exchange` guard is not enough: it lets a second caller reach `_acceptThread.join()` while the first is inside it — two joins on one `std::thread`, which hangs forever on Linux/glibc and throws `std::system_error` on macOS/libc++. The wakeup write runs under that same mutex and at most once per `listen()`/`close()` cycle. Racing `close()` against the *destructor* remains out of contract, as for any member call. | ## `executeInto` — settling the caller's own completion @@ -2490,7 +2430,7 @@ not a behavior change to the existing loopback-only default. **is** the typed `CompletionState` its caller holds. That removes the `.then`/`.onError` block that used to forward the backend's erased completion into the typed one — six allocations per call of the 14.06 a local round trip -took, measured down to 8.06 (morph#572, Part B; see +took, measured down to 8.06 (see [`bridge.md`](bridge.md#bridgesink--the-typed-state-the-backend-settles)). **Why a default rather than a pure virtual.** `execute` is implemented by five @@ -2521,14 +2461,14 @@ implementation to absorb — see | Decision | Choice | Why | |---|---|---| | Dual-path `ActionCall` | Three callables: `localOp`, `serializeAction`, `deserializeResult` | The same `ActionCall` struct works for both local and remote execution without an `if (isRemote)` branch at the call site — each backend uses the field(s) it needs. | -| Callables are function pointers, not `std::function`s | `std::string (*)(const void*)` etc., with the action in `ActionCall::action` | Every call builds all three, whichever path it takes, so a stateful callable charges an allocation to calls that never invoke it. The behaviour is a constant of `(Model, Action)`; only the action is per-call. Measured at 3 allocations per local round trip (morph#572, Part A). The cost is an explicit borrow: see [Lifetime contract](#lifetime-contract). | +| Callables are function pointers, not `std::function`s | `std::string (*)(const void*)` etc., with the action in `ActionCall::action` | Every call builds all three, whichever path it takes, so a stateful callable charges an allocation to calls that never invoke it. The behaviour is a constant of `(Model, Action)`; only the action is per-call. Measured at 3 allocations per local round trip. The cost is an explicit borrow: see [Lifetime contract](#lifetime-contract). | | Type ids are `string_view`s, not `std::string`s | `modelTypeId`, `actionTypeId` | They are always views of `constexpr` string literals from the registration macros, so copying them into a `std::string` bought nothing and allocated whenever an id exceeded the SSO threshold (`"CreateSwimlane"` is 14 characters; the margin is one character wide). | -| `registerModelWithContext` | Virtual with a default that drops `contextKey` | `LocalBackend`'s factory closure already captures identity, so there is nothing to forward — which is why the default drops the key rather than being pure virtual. A backend whose instances are constructed on the far side of a wire protocol has no such closure, so the envelope is the only channel the identity has: `SimulatedRemoteBackend` and `SocketBackend` both override it so the server's `LogProvider` can attach an action log. The default being *permissive* is what let `SocketBackend` ship without an override and silently stop journalling private registrations (morph#587); the price of that permissiveness is that "is this a wire backend?" has to be answered by hand for each new transport. | +| `registerModelWithContext` | Virtual with a default that drops `contextKey` | `LocalBackend`'s factory closure already captures identity, so there is nothing to forward — which is why the default drops the key rather than being pure virtual. A backend whose instances are constructed on the far side of a wire protocol has no such closure, so the envelope is the only channel the identity has: `SimulatedRemoteBackend` and `SocketBackend` both override it so the server's `LogProvider` can attach an action log. The default being *permissive* is what lets a wire backend ship without an override and silently stop journalling private registrations; the price of that permissiveness is that "is this a wire backend?" has to be answered by hand for each new transport. | | `RemoteServer` heap requirement | `std::enable_shared_from_this` | `handle()` posts to the worker pool capturing `shared_from_this()` — the server must outlive any in-flight message. | | `handleInline` | Synchronous; caller-restricted to control messages | Safe to call from a worker-pool thread (e.g. from a `BridgeHandler` constructor). It is meant for `register`/`deregister` only; an `execute` envelope is rejected with an `err` reply, because `dispatchExecute` posts to the strand and would reply after `handleInline` returns (writing into an already-destroyed reply buffer). The rejection is now enforced by the code, matching the documented intent. | | `SimulatedRemoteBackend` factory ignored | Model construction delegated to `RemoteServer`'s `ModelRegistryFactory` | The factory closure lives on the client side; the server owns the actual instances. | | `cancelPending` snapshots | Weak-ptr snapshot under lock, then resolves outside | Avoids holding the lock while delivering exceptions to each state, preventing deadlock if a callback re-enters the backend. | -| `_pending` compacted amortised, not intrusively | Sweep when `size() >= _compactAt`, re-arm at twice the survivors | The alternative considered in morph#528 was intrusive: give `CompletionState` a slot index and unlink on settle, making both registration and removal O(1) with no sweep at all. Rejected. It pushes a back-reference to the backend's table into a type shared by every backend, and puts a `_pendingMtx` acquisition on the settle path of every completion — turning a cost paid once per burst into contention paid by every strand thread on every result, on the exact path morph#579's value-handling contract just fixed. The amortised sweep buys the same O(1) admission for one `size_t` of state confined to `LocalBackend`, at the cost of a list bounded at 2× the live count instead of exactly it. | +| `_pending` compacted amortised, not intrusively | Sweep when `size() >= _compactAt`, re-arm at twice the survivors | The intrusive alternative is to give `CompletionState` a slot index and unlink on settle, making both registration and removal O(1) with no sweep at all. Rejected. It pushes a back-reference to the backend's table into a type shared by every backend, and puts a `_pendingMtx` acquisition on the settle path of every completion — turning a cost paid once per burst into contention paid by every strand thread on every result, on the exact path `Completion`'s value-handling contract exists to keep cheap. The amortised sweep buys the same O(1) admission for one `size_t` of state confined to `LocalBackend`, at the cost of a list bounded at 2× the live count instead of exactly it. | | `setReconnectHandler` | Default no-op | Only backends with a transport layer (e.g. `QtWebSocketBackend`) need to react to reconnects. `LocalBackend` and `SimulatedRemoteBackend` never invoke it. | | `setConnectHandler`/`setDisconnectHandler` on `IBackend`, not only `QtWebSocketBackend` | Same no-op-default pattern as `setReconnectHandler` | Connection state is a property of any transport-backed backend; a UI observing it shouldn't have to downcast to a concrete backend type. A purely local backend has no meaningful connection state, so the base-class hook is simply inert for it — no behavior change, matching the existing `setReconnectHandler` precedent exactly. | | `setDisconnectHandler` fires before reconnect scheduling | Ordering choice, not incidental | An instant successful reconnect must not look, from an observer's perspective, like nothing happened — the disconnected state must be visible even when the very next thing that happens is a fresh `connected`. | @@ -2537,7 +2477,7 @@ implementation to absorb — see | Opaque model ids | Monotonic counter run through a keyed 4-round Feistel permutation (`detail::OpaqueIdGenerator`), key drawn from `std::random_device` at construction | Guarantees uniqueness (Feistel networks are bijections for any round function) while making ids unguessable without the key; self-contained, no external crypto dependency — same posture as the reference HMAC-SHA256 in `session_auth.hpp`. | | WebSocket `deregisterModel` is fire-and-forget | Send-only, no nested event loop | A synchronous deregister would need a nested `QEventLoop`, which is typically driven from a destructor (`~BridgeHandler`) and can trip Qt asserts. A lost/undelivered deregister no longer leaks indefinitely: `QtWebSocketServer`'s connection scope reclaims the model at the next disconnect (see Limitations). | | Connection-scoped cleanup bypasses `IAuthorizer` | `closeConnection` never calls `authorize`/`authorizeInstance`/`authenticate` | It is server housekeeping triggered by the transport's own connection-close event, not a caller action; synthesising a `deregister` envelope would need a token to pass ownership checks and would require the transport to learn ids by parsing replies — recording the owning connection at register time is simpler and cannot desync. | -| `callId`-multiplexed replies | `execute` replies carry a non-zero `callId`; *awaited* control replies carry `0`. The one exception is the fire-and-forget `deregister`, which carries a non-zero `callId` from the same counter on both WebSocket transports | Lets `QtWebSocketBackend` run many concurrent async executes over one socket and match each reply to its `Completion`, while still supporting the parked-nested-loop synchronous `register` path (which uses `callId == 0`). The `deregister` exception exists because `0` means "give this payload to whoever is parked in `sendSync`", and `deregister` is the one control message nobody parks for: with `callId == 0` its own unwanted `ok` was handed to an unrelated `register`/`attach` that happened to be parked, which returned that reply's `modelId` of `0` (issue #65 for `QtWebSocketBackend`, issue #454 for `SocketBackend`). Every message whose reply *is* awaited synchronously still uses `0`. | +| `callId`-multiplexed replies | `execute` replies carry a non-zero `callId`; *awaited* control replies carry `0`. The one exception is the fire-and-forget `deregister`, which carries a non-zero `callId` from the same counter on both WebSocket transports | Lets `QtWebSocketBackend` run many concurrent async executes over one socket and match each reply to its `Completion`, while still supporting the parked-nested-loop synchronous `register` path (which uses `callId == 0`). The `deregister` exception exists because `0` means "give this payload to whoever is parked in `sendSync`", and `deregister` is the one control message nobody parks for: with `callId == 0` its own unwanted `ok` is handed to an unrelated `register`/`attach` that happens to be parked, which then returns that reply's `modelId` of `0`. Every message whose reply *is* awaited synchronously still uses `0`. | | Reconnect handler skipped on first connect | Fired only when `_everConnected` was already true | The initial handler registration is driven by `BridgeHandler` constructors; firing the reconnect handler on the very first connect would double-register. | | No reconnect for never-connected sockets | `disconnected` schedules a retry only if `_everConnected` | A socket that never reached the server (bad URL / refused) fails fast via `waitForConnected` returning false, rather than backing off forever. | | Server reply marshalled to the Qt thread | `QMetaObject::invokeMethod(..., QueuedConnection)` with a `QPointer` | `RemoteServer::handle` produces the reply on a pool thread, but `QWebSocket::sendTextMessage` must run on the Qt thread; the weak `QPointer` drops the reply cleanly if the client disconnected meanwhile. | @@ -2548,12 +2488,12 @@ implementation to absorb — see | `morph::net`'s I/O model | A dedicated I/O thread + `std::condition_variable`, instead of the Qt event loop | Lets `SocketBackend`/`SocketServer` run with no GUI event loop and no Qt dependency, and — as a side effect — lets `SocketBackend` be driven safely from multiple threads (`QtWebSocketBackend` cannot be, since it is pinned to one event-loop thread). | | `morph::net` frame/handshake implementation | Hand-rolled RFC 6455 (SHA-1 + base64 + HTTP Upgrade + frame codec), not a third-party library | The spec's own interop requirement (a `morph::net` client/server must talk to the real Qt transport and vice versa) rules out a bespoke non-WebSocket framing; hand-rolling avoids adding a dependency to keep morph's default build dependency-free, and RFC 6455's core (handshake + frame codec, including fragment reassembly) is a small, bounded surface. | | `WsFrameReader` reassembles fragments | Accumulates continuation frames and returns only the completed message | Fragmentation is not an exotic case: a peer fragments whenever a message exceeds its outgoing frame size, and Qt's `QWebSocket` defaults that to 512 KiB. Rejecting fragments broke interop with the transport this project ships, for every payload past that size. Control frames interleaved between fragments pass through untouched, and the reassembled total is bounded by `wire::kMaxEnvelopeBytes` so a stream of tiny continuations cannot grow the buffer without limit. | -| `WsFrameReader` rejects RFC 6455-illegal frames instead of tolerating them | Masking direction, RSV bits, opcode range, control-frame framing, Close status code, minimal length encoding and text-payload UTF-8 are all checked; a violation throws out of `tryExtractFrame()` and the call site drops the connection | The interop requirement above makes what the reader *refuses* part of the transport's contract rather than an implementation detail: ten classes of illegal frame used to be accepted, and a peer that sends one now gets disconnected (morph#533). The reader is given its role at construction (`expectMasked`) because §5.1 is directional — a server MUST reject an unmasked client frame and a client MUST reject a masked server frame, and that rule is the anti-cache-poisoning defence, not a formality. Text UTF-8 is validated incrementally, since a multi-byte sequence may straddle a fragment boundary. On the sending side the mask key is drawn per frame from a thread-local `std::random_device` rather than a thread-local `std::mt19937`, whose state a peer can reconstruct from 624 observed keys (§5.3); `random_device` has no reproducible state to recover, and holding it thread-local keeps the entropy source open instead of reacquiring it on every outbound message. | -| Registration continuation delivered via a caller-supplied `IExecutor&`, not on the backend's thread | `bindModel`/`promoteModel` return a `Completion` built with the caller's executor | The four `*Async` twins' threading contract could only be stated in prose, and its violation is a use-after-free (morph#486/#489). Making the executor an argument moves the choice of delivery thread from fifteen implementors that know nothing about the caller's teardown to the one caller that does, and turns it from a `@note` into a value a call site must produce. Rejected: matching `execute`'s `IExecutor*` — a null pointer makes `Completion` drop every handler silently, which is the same unobservable failure the surface removes. | +| `WsFrameReader` rejects RFC 6455-illegal frames instead of tolerating them | Masking direction, RSV bits, opcode range, control-frame framing, Close status code, minimal length encoding and text-payload UTF-8 are all checked; a violation throws out of `tryExtractFrame()` and the call site drops the connection | The interop requirement above makes what the reader *refuses* part of the transport's contract rather than an implementation detail: a tolerant reader accepts ten classes of illegal frame, and a peer that sends one here gets disconnected instead. The reader is given its role at construction (`expectMasked`) because §5.1 is directional — a server MUST reject an unmasked client frame and a client MUST reject a masked server frame, and that rule is the anti-cache-poisoning defence, not a formality. Text UTF-8 is validated incrementally, since a multi-byte sequence may straddle a fragment boundary. On the sending side the mask key is drawn per frame from a thread-local `std::random_device` rather than a thread-local `std::mt19937`, whose state a peer can reconstruct from 624 observed keys (§5.3); `random_device` has no reproducible state to recover, and holding it thread-local keeps the entropy source open instead of reacquiring it on every outbound message. | +| Registration continuation delivered via a caller-supplied `IExecutor&`, not on the backend's thread | `bindModel`/`promoteModel` return a `Completion` built with the caller's executor | A per-verb non-blocking twin can only state its threading contract in prose, and a violation of it is a use-after-free. Making the executor an argument moves the choice of delivery thread from fifteen implementors that know nothing about the caller's teardown to the one caller that does, and turns it from a `@note` into a value a call site must produce. Rejected: matching `execute`'s `IExecutor*` — a null pointer makes `Completion` drop every handler silently, which is the same unobservable failure the surface removes. | | One `bindModel` instead of three acquire verbs | Behaviour selected by `BindRequest`'s shape (`primary` empty?, `current` zero?) | The three verbs already degrade into each other exactly along those two fields, so naming them separately stated the same distinction three times — and tripled it again for the `*Async` twins. The default implementation still routes each shape to the legacy verb it names, so a backend that overrides only some of the three is unaffected. | -| `SynchronousBackendAdapter` is a decorator, not a base class or a CRTP mixin | Wraps `shared_ptr` and forwards every verb | It must work on `LocalBackend` and eleven test doubles *without modifying them*, which rules out anything they would have to derive from. Cost is one forwarding method per unchanged verb; benefit is that morph#522's remaining four steps are migrations rather than rewrites. | +| `SynchronousBackendAdapter` is a decorator, not a base class or a CRTP mixin | Wraps `shared_ptr` and forwards every verb | It must work on `LocalBackend` and eleven test doubles *without modifying them*, which rules out anything they would have to derive from. Cost is one forwarding method per unchanged verb; benefit is that an unmodified blocking backend reaches the new surface at all. | | The adapter's blocking executor is required, not defaulted | Constructor parameter with no default; null `inner` throws | "Where does the blocking happen" is the only question the class exists to answer. An adapter that silently ran the call inline when handed nothing would block on some configurations and not others — contract by configuration, which is the thing being removed. | -| `SocketBackend` runs reconnect handlers on a dedicated thread | Not inline from the I/O thread's connect path | A reconnect handler re-registers models via the synchronous control path, which waits for a reply only the I/O thread's read loop can deliver. Inline, that wait blocks the very thread that would satisfy it, deadlocking the transport with no timeout. Still load-bearing after morph#569: `bindModel` cannot deadlock this way, but the blocking verbs still can and `Bridge`'s reconnect handler still calls them. | +| `SocketBackend` runs reconnect handlers on a dedicated thread | Not inline from the I/O thread's connect path | A reconnect handler re-registers models via the synchronous control path, which waits for a reply only the I/O thread's read loop can deliver. Inline, that wait blocks the very thread that would satisfy it, deadlocking the transport with no timeout. Still load-bearing even though `bindModel` cannot deadlock this way: the blocking verbs can, and a caller may still reach them. | | `SocketBackend` implements `bindModel`/`promoteModel` natively | Not wrapped in `SynchronousBackendAdapter`, although it overrides none of the four `*Async` verbs | The I/O thread already demultiplexes replies by `callId` for `execute` and `RemoteServer` echoes `callId` on every control reply, so the non-blocking path costs a second `PendingCallTable` and no protocol change. Wrapping instead would park a thread per bind for a round trip this transport need not park for, and would keep every bind inside `sendSync`'s one-synchronous-call token — which `listInstances` and the legacy `registerModel` share, and which is the one global restriction on a backend otherwise documented as drivable from several threads at once. The adapter's reconnect-handler property, the other reason to consider it, does not apply: it forwards `setReconnectHandler` and the blocking verbs straight through, so a wrapped `SocketBackend` would run reconnect control calls exactly where it does today. | ## Lifetime annotations @@ -2637,7 +2577,7 @@ it. See [concurrency_and_lifetimes.md](../concurrency_and_lifetimes.md#morph_lif documented future work, not implemented today. Nothing in `SocketServer`'s teardown depends on which of those kernels it runs on any more: the accept loop is interrupted by a wakeup file descriptor the server owns, not by any - kernel's treatment of `shutdown(2)` on a listening socket (morph#437). One + kernel's treatment of `shutdown(2)` on a listening socket. One half of that is still unverified first-hand — **the macOS symptom has never been reproduced on a Darwin machine by this project's own measurement**; the original report's `sample(1)` backtrace and documented BSD semantics are the @@ -2675,7 +2615,7 @@ it. See [concurrency_and_lifetimes.md](../concurrency_and_lifetimes.md#morph_lif learns only that the connection went away, and neither side logs which check failed. Sending the RFC status code needs the reader's error model to change from throwing to `std::expected<..., WsProtocolError>` at both call sites; - that is separable work, deliberately not done in morph#533. A Close frame + that is separable work and is not done here. A Close frame *from* the peer is a different path and is not a violation: it is echoed back carrying the peer's own status code (§5.5.1), where it used to be echoed empty. @@ -2690,7 +2630,7 @@ it. See [concurrency_and_lifetimes.md](../concurrency_and_lifetimes.md#morph_lif `Config::handshakeTimeout`.** If destruction races an in-flight (re)connect attempt, the TCP connect phase is bounded by the former and the handshake response read that follows a successful TCP connect is bounded by the - latter (`SO_RCVTIMEO` on the not-yet-published socket; morph#535 — a peer + latter (`SO_RCVTIMEO` on the not-yet-published socket — a peer that completes the TCP handshake but never speaks, or never finishes, the WebSocket Upgrade used to leave the I/O thread, and therefore the destructor's join, waiting forever). Neither bound is a hard real-time diff --git a/docs/spec/core/bridge.md b/docs/spec/core/bridge.md index b863c5104..9a0844278 100644 --- a/docs/spec/core/bridge.md +++ b/docs/spec/core/bridge.md @@ -114,7 +114,7 @@ surviving handler had before the disconnect. A non-shared binding is unaffected and still re-registers through the `registerModelWithContext` shape. -Since morph#615 the handler reaches the backend through +The handler reaches the backend through `IBackend::bindModel` — the structural registration surface — and consults `IBackend::bindWaitPolicy()` exactly as `registerHandlerImpl` does. It matters here more than anywhere: the handler runs on the backend's *transport* thread, @@ -229,15 +229,15 @@ in this change: | Invariant | Where it lives now | |---|---| -| The deadline is disarmed **first**, before any forwarding work, so a slow `onResult`/`publishResult` cannot give the timer a window to resolve the completion with `ClientTimeoutError` while the real result is in hand (morph#620) | `BridgeSink::settleOnce`, called at the top of both settle methods | -| `pendingCalls()` is decremented on **exactly one** of two mutually-exclusive paths, whether or not the forwarding that follows then throws (morph#489) | `settleOnce`'s latch | -| `lifetime->alive` is read **once**, and that one snapshot decides both `onResult` and `publishResult` (morph#486/#489) | `BridgeSink::settleValue` | -| The value forwarding is `try`/`catch`-guarded, so a throwing move of `R` reaches the error sink rather than the callback executor (morph#502) | `BridgeSink::settleValue` | -| A throw out of the backend undoes both the pending count and the deadline (morph#502) | `BridgeSink::abandon`, routed through the same latch | - -The latch is new, and it is load-bearing. Before `BridgeSink`, "exactly one -decrement per dispatch" was carried by `.then` and `.onError` being mutually -exclusive on one `CompletionState`. A sink has no such guarantee: +| The deadline is disarmed **first**, before any forwarding work, so a slow `onResult`/`publishResult` cannot give the timer a window to resolve the completion with `ClientTimeoutError` while the real result is in hand | `BridgeSink::settleOnce`, called at the top of both settle methods | +| `pendingCalls()` is decremented on **exactly one** of two mutually-exclusive paths, whether or not the forwarding that follows then throws | `settleOnce`'s latch | +| `lifetime->alive` is read **once**, and that one snapshot decides both `onResult` and `publishResult` | `BridgeSink::settleValue` | +| The value forwarding is `try`/`catch`-guarded, so a throwing move of `R` reaches the error sink rather than the callback executor | `BridgeSink::settleValue` | +| A throw out of the backend undoes both the pending count and the deadline | `BridgeSink::abandon`, routed through the same latch | + +The latch is load-bearing. With a plain `Completion`, "exactly one decrement per +dispatch" is carried by `.then` and `.onError` being mutually exclusive on one +`CompletionState`. A sink has no such guarantee: `IBackend::cancelPending` settles it from one thread while a reply may be settling it from another. Without the latch the second settle decrements `_pendingCalls` again, and the counter is a `std::size_t` — a second decrement @@ -274,10 +274,10 @@ 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. Since -morph#615 phase 1 acquires each instance through `IBackend::bindModel` and -consults `IBackend::bindWaitPolicy()`, and **atomicity is exactly as strong as -the wait is**: +switch either fully succeeds or leaves everything exactly as it was. Phase 1 +acquires each instance through `IBackend::bindModel` and consults +`IBackend::bindWaitPolicy()`, and **atomicity is exactly as strong as the wait +is**: - `kCallerMayBlock` (every backend in the tree that is not a `SynchronousBackendAdapter` or a WASM-configured `QtWebSocketBackend`): the @@ -429,7 +429,7 @@ cancelled/backend-changed/disconnected completion, all of which resolve through `.onError`). The synchronous "handler not bound" early return in `executeVia()` never increments the counter in the first place (it resolves before any dispatch), so it needs no matching decrement. Heap-allocated and -captured by value into both continuations (morph#489) rather than reached +captured by value into both continuations rather than reached through `this`: a completion can resolve after `~Bridge()` runs on another thread, and touching `this` on a dangling `Bridge` would be a use-after-free, so the counter is pinned independently of the `Bridge` and decremented @@ -462,8 +462,8 @@ deregistration, a reconnect can call into a backend's synchronous `registerModelWithContext`/`registerModelShared`, which blocks on a nested event loop for `QtWebSocketBackend` — holding the lifetime gate across that would let `~Bridge` block for the same round trip, and if the reconnect and the -destructor ever land on the same thread, deadlock rather than a slow teardown -(morph#489, tracked as still open for this one call site). +destructor ever land on the same thread, deadlock rather than a slow teardown. +This one call site therefore keeps the weaker guard. **`liveness()`** (private, exposed only to `BridgeHandler` via friendship) returns a `morph::async::CallbackToken` issued from the bridge's `_callbacks` @@ -481,7 +481,7 @@ half — it never calls `requestStop()`, so its tokens go inactive only when the ### The bridge's own executor `Bridge`'s constructor takes an optional second argument, `IExecutor* -bridgeExec` (morph#588). Every other completion in the framework is delivered +bridgeExec`. Every other completion in the framework is delivered on an executor its caller named — `BridgeHandler` supplies `guiExec`, `executeVia` takes a `cbExec`. The registrations the bridge issues *on its own behalf* had no such executor, and its five dispatch sites @@ -498,8 +498,7 @@ settles while the dispatch call is still on the stack is parked by dispatching thread, whatever `bridgeExec` says. The `bindModel`/`promoteModel` calls therefore keep naming `inlineExecutor()`. -That is a decision, not an omission left over from morph#568, and two things -break if it is changed: +That is a decision, not an omission, and two things break if it is changed: - **`registerHandler()` stops being synchronous.** For every backend that binds inline — `LocalBackend`, `SimulatedRemoteBackend`, any `kCallerMayBlock` @@ -518,15 +517,15 @@ the bridge *and* every registration still in flight when the bridge is destroyed, because a late reply can land after `~Bridge` (the same requirement `BridgeHandler`'s `guiExec` already carries). -**What it buys, stated exactly.** The morph#486 window in these callbacks is +**What it buys, stated exactly.** The window in these callbacks is "check `CallbackToken::active()`, then touch the bridge". It is closed only if `bridgeExec` runs its tasks on a thread that cannot run `~Bridge` concurrently — for a Qt embedder, the GUI thread that both owns the `Bridge` and pumps the executor; the callback and the destructor are then two tasks on one thread and cannot interleave. An executor on some *other* thread satisfies the type and closes nothing. It makes nothing worse either: the callbacks' existing -`CallbackToken`/`detail::BridgeLifetime` gates are unchanged, and with the null -default the delivery is inline, byte for byte the pre-morph#588 behaviour. +`CallbackToken`/`detail::BridgeLifetime` gates still apply, and with the null +default the delivery is inline. `tests/test_async_registration.cpp` pins all three halves: a late reply is queued on the executor and publishes nothing until it is drained (restoring @@ -551,8 +550,8 @@ if the gate still reports `alive`. If the `Bridge` was already destroyed the flag is clear, so the destructor skips deregistration instead of dereferencing a dangling `Bridge&`; and if it was not, `~Bridge` cannot start while this call is in progress. Holding the lock rather than merely reading a flag is the point: -a bare check answers for an instant that has already passed, which is what made -issue #486 a use-after-free. Destroying the bridge before its handlers is still +a bare check answers for an instant that has already passed, which is what turns +this shape into a use-after-free. Destroying the bridge before its handlers is discouraged (see [Lifetime & ownership](#lifetime--ownership)), but it is defined behaviour on any thread, not a use-after-free. @@ -727,7 +726,7 @@ an ordinary interleaving rather than an exotic race. `CompletionState` now refuses to settle on a null at all ([completion.md](completion.md#setting-a-value-or-exception)); this site supplies the specific message because the meaning of *this* failure is known -here and nowhere else. See issue #347. +here and nowhere else. Scope limits worth knowing, because each is a question `whenBound()` looks like it answers and does not: @@ -735,11 +734,11 @@ it answers and does not: - **It tracks the initial registration, plus a re-registration that could not be waited for.** `registerHandlerImpl` sets `registrationInFlight` on every path. `switchBackend()` and the reconnect handler set it only when the new - backend answers `kCallerMustNotBlock` (morph#615) — the one case where they - leave a binding unbound with a reply still to come, which is exactly the - state `whenBound()` exists to describe. Against a `kCallerMayBlock` backend - both still settle every outcome inside their own frame and set nothing, so - `whenBound()` says nothing about a swap or reconnect there, as before. + backend answers `kCallerMustNotBlock` — the one case where they leave a + binding unbound with a reply still to come, which is exactly the state + `whenBound()` exists to describe. Against a `kCallerMayBlock` backend both + settle every outcome inside their own frame and set nothing, so `whenBound()` + says nothing about a swap or reconnect there. - **It does not track the shared attach path.** `attachHandler`/`ensureBound` and their async counterparts bind a shared handler without going through `registerHandlerImpl`, so for an `AllowShared` handler `whenBound()` is only @@ -764,10 +763,10 @@ the server-side `ActionDispatcher` uses over the two strings alone. Populated by `registerActionExecutorOnce()`, which `BRIDGE_REGISTER_ACTION` calls during static initialization. -**No key is built to look one up** (morph#699). `KeyHash` and `KeyEqual` are +**No key is built to look one up.** `KeyHash` and `KeyEqual` are both transparent, so `execute` probes with `KeyView` -(`{string_view modelId; string_view actionId; std::type_index sharing;}`) and -the two `std::string`s the old `Key{...}` temporary constructed are gone. +(`{string_view modelId; string_view actionId; std::type_index sharing;}`) +rather than constructing a `Key{...}` temporary and its two `std::string`s. `KeyView` is a type of its own rather than a reuse of `morph::model::detail::PairKeyView`: this key carries the sharing tag as well as the two ids, and a reuse would have to drop it. Both `KeyHash` overloads @@ -964,7 +963,7 @@ already-registered binding, while this read happens *during* registration. The pre-built-binding `registerHandler(binding)` overload hands the caller the binding first, so the requirement falls on the caller: **set `contextKey` before calling `registerHandler()`, and do not mutate it concurrently with that call.** -Afterwards the ordinary `_attachMtx` rule applies. See morph#505. +Afterwards the ordinary `_attachMtx` rule applies. The guarantee is unconditional, including for a backend that completes its `bindModel` completion **inline** — from inside @@ -1015,7 +1014,7 @@ on the next `publishResult` call, not the moment the handler dies — rather than dangling. The intended usage remains **single-GUI-thread affinity**: a handler and its subscriptions belong to one GUI thread. -Heap-allocated behind a `shared_ptr` (morph#489) rather than a plain member for +Heap-allocated behind a `shared_ptr` rather than a plain member for the same reason `_pendingCalls` is: `executeVia()`'s `.then` continuation calls `hasSubscribers()`/`publishResult()` from a context that can run after `~Bridge()`, and the registry's own "snapshot under lock, invoke outside it" @@ -1059,10 +1058,10 @@ does not run, so a check that has gone stale costs nothing. It cannot gate a *member call on the `Bridge`*, because `CallbackToken::active()` is explicitly advisory across threads ([callback_scope.md](callback_scope.md), "Boundary of the guarantee") — the bridge can be destroyed between the check and the call. -`~BridgeHandler` used a bare `active()` check until issue #486, where a -`shared_ptr` kept alive by its own completions was released on a -worker-pool thread while the owning thread ran `~App`: the check passed, the -`Bridge` finished being destroyed, and `deregisterHandler` then walked the freed +A bare `active()` check in `~BridgeHandler` admits exactly that: a +`shared_ptr` kept alive by its own completions is released on a +worker-pool thread while the owning thread runs `~App`, the check passes, the +`Bridge` finishes being destroyed, and `deregisterHandler` then walks the freed `_handlers`. The gate replaces the check with something that holds. **Blocking, and why it is safe.** `~Bridge` waits for an in-flight @@ -1100,13 +1099,13 @@ make teardown order-independent.) | Member | Signature | Notes | |---|---|---| -| ctor | `explicit Bridge(unique_ptr, IExecutor* bridgeExec = nullptr)` | Installs reconnect handler on the backend, then pushes the (initially empty) default session via `setSession`. `bridgeExec` is where a registration reply that arrived after its dispatch frame is delivered; null (the default) delivers it inline, exactly as before morph#588. See [The bridge's own executor](#the-bridges-own-executor). | +| ctor | `explicit Bridge(unique_ptr, IExecutor* bridgeExec = nullptr)` | Installs reconnect handler on the backend, then pushes the (initially empty) default session via `setSession`. `bridgeExec` is where a registration reply that arrived after its dispatch frame is delivered; null (the default) delivers it inline. See [The bridge's own executor](#the-bridges-own-executor). | | dtor | `~Bridge()` | Clears the active backend's reconnect handler, then cancels all pending completions with `BridgeDestroyedError`. | | `registerHandler` | `shared_ptr registerHandler()` | Default factory. Dispatches `IBackend::bindModel`; see `backend.md`. | | `registerHandler(binding)` | `void registerHandler(const shared_ptr&)` | Pre-built binding. Same async-preferring behavior. | | `switchBackend` | `void switchBackend(unique_ptr)` / `void switchBackend(shared_ptr)` | Pushes the current default session onto the new backend via `setSession` before staging. Stages all re-registrations through `bindModel` on the new backend, commits (publishes new ids + swaps) only if all succeed, else rolls back and rethrows leaving old backend + `currentId`s intact. Atomic exactly when the new backend answers `kCallerMayBlock`; a `kCallerMustNotBlock` backend's binds are deferred and the switch is not all-or-nothing (see above). Cancels old backend's pending ops with `BackendChangedError`. Holds both `_mtx` and `_attachMtx` for its staging and commit, and resolves `whenBound()` waiters after releasing them. The `unique_ptr` overload is a template on the concrete backend type and delegates to the `shared_ptr` one — see below. | | `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 a journal `LogEntry` for loggable actions on both success (`Outcome::Succeeded`) and a throwing `Model::execute` (`Outcome::Failed`, rethrown unchanged). Dispatches through `IBackend::executeInto`, handing the backend a `detail::BridgeSink` that is simultaneously the caller's typed completion state and the backend's settle sink — one allocation where the erased-completion forwarding block cost six (morph#572, Part B). 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`, the `pendingCalls()` decrement, and the execute-deadline disarm) are gated on the bridge's `CallbackToken`, checked before any runs, so a completion resolving after `~Bridge()` skips them instead of touching the dangling `Bridge`. Increments `pendingCalls()` once per call before dispatch (never for the synchronous "handler not bound" early return); decrements it exactly once, from whichever of the two mutually-exclusive resolution continuations actually fires. Arms the client-side execute deadline when one is installed (see `setExecuteDeadline`); the fast-fail "handler not bound" path returns before that and arms nothing. | +| `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 a journal `LogEntry` for loggable actions on both success (`Outcome::Succeeded`) and a throwing `Model::execute` (`Outcome::Failed`, rethrown unchanged). Dispatches through `IBackend::executeInto`, handing the backend a `detail::BridgeSink` that is simultaneously the caller's typed completion state and the backend's settle sink — one allocation where an erased-completion forwarding block costs six. 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`, the `pendingCalls()` decrement, and the execute-deadline disarm) are gated on the bridge's `CallbackToken`, checked before any runs, so a completion resolving after `~Bridge()` skips them instead of touching the dangling `Bridge`. Increments `pendingCalls()` once per call before dispatch (never for the synchronous "handler not bound" early return); decrements it exactly once, from whichever of the two mutually-exclusive resolution continuations actually fires. Arms the client-side execute deadline when one is installed (see `setExecuteDeadline`); the fast-fail "handler not bound" path returns before that and arms nothing. | | `setDefaultSession` | `void setDefaultSession(session::Context)` | Installs default session context; also pushes it to the active backend via `IBackend::setSession` so control envelopes (register/attach/assign/deregister) carry it too, not only `execute`. | | `defaultSession` | `session::Context defaultSession() const` | Returns snapshot of default session. | | `setExecuteDeadline` | `void setExecuteDeadline(std::chrono::milliseconds)` | Opt-in client-side execute deadline; `0` (the default) disables it. Lazily creates the backing `TimeoutScheduler` thread on first enable. | @@ -1155,7 +1154,7 @@ 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 `detail::BridgeLifetime` gate — not by this weak storage.) | -| Teardown order | **`shared_ptr` — a `shared_mutex` + `alive` flag, shared with every handler** | Makes bridge-vs-handler destruction order-independent on any thread: `~BridgeHandler` holds the gate shared across its whole deregistration, `~Bridge`'s first statement takes it exclusively and clears `alive`, so the two can never overlap. Normal `execute`/`subscribe` still require the bridge to outlive its handlers. This replaced a per-handler `CallbackToken` from `_callbacks`, whose `active()` is advisory across threads — check-then-call, which issue #486 hit as a use-after-free. The `CallbackScope` stays for what it is right for: gating *delivery* of the bridge's own callbacks ([callback_scope.md](callback_scope.md)). | +| Teardown order | **`shared_ptr` — a `shared_mutex` + `alive` flag, shared with every handler** | Makes bridge-vs-handler destruction order-independent on any thread: `~BridgeHandler` holds the gate shared across its whole deregistration, `~Bridge`'s first statement takes it exclusively and clears `alive`, so the two can never overlap. Normal `execute`/`subscribe` still require the bridge to outlive its handlers. A per-handler `CallbackToken` from `_callbacks` cannot do this job: its `active()` is advisory across threads, so the check-then-call it permits is a use-after-free. The `CallbackScope` stays for what it is right for: gating *delivery* of the bridge's own callbacks ([callback_scope.md](callback_scope.md)). | | 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. | @@ -1167,7 +1166,7 @@ make teardown order-independent.) | `executeJson` | **Separate registry, not a vtable** | The action type is unknown at the call site. A flat `unordered_map` keyed on registered ids lets any translation unit register its actions without central registration or RTTI. | | Executor keying | **`(modelId, actionId, typeid(Sharing))` — one executor per sharing policy** | The executor is the only place a typed handler is recovered from a `void*`, and `BridgeHandler` / `BridgeHandler` are unrelated types. A single `NoSharing`-only executor handed a shared handler would `static_cast` to the wrong instantiation, so its `kShared` — which gates a payload-/result-keyed action's attach-or-promote step — would answer for the wrong one and that step would silently never run. No runtime type information survives to check it by then, so the key carries the distinction instead: two entries per action, built from one generic-lambda template so they cannot diverge, and `execute` templated on `Sharing` so each call site selects its own. Costs one closure per registered action at static-init time, not per call. | | `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. | -| `pendingCalls()` counter placement | **One `std::atomic` on `Bridge`, not per-`HandlerBinding` or per-backend** | Issue #45 asks for client-side quiescence — "has everything settled" — which is a property of the `Bridge` as a whole (the thing the GUI actually holds one of), not of any single handler or backend. A per-binding counter would force a caller wanting a global "still loading" signal to sum across every live `BridgeHandler`; a per-backend counter (mirroring `LocalBackend::_inFlight`/`RemoteServer`'s `_inFlightExecutes`, both server/backend-side) would miss calls dispatched before a `switchBackend()` mid-flight. Incrementing/decrementing directly in `executeVia()` — the one chokepoint every dispatch path (`execute`, `executeJson`) funnels through — needs no cooperation from `IBackend` implementations at all. | +| `pendingCalls()` counter placement | **One `std::atomic` on `Bridge`, not per-`HandlerBinding` or per-backend** | Client-side quiescence — "has everything settled" — is a property of the `Bridge` as a whole (the thing the GUI actually holds one of), not of any single handler or backend. A per-binding counter would force a caller wanting a global "still loading" signal to sum across every live `BridgeHandler`; a per-backend counter (mirroring `LocalBackend::_inFlight`/`RemoteServer`'s `_inFlightExecutes`, both server/backend-side) would miss calls dispatched before a `switchBackend()` mid-flight. Incrementing/decrementing directly in `executeVia()` — the one chokepoint every dispatch path (`execute`, `executeJson`) funnels through — needs no cooperation from `IBackend` implementations at all. | ## Limitations diff --git a/docs/spec/core/callback_scope.md b/docs/spec/core/callback_scope.md index 7e458704c..9b5dd0b90 100644 --- a/docs/spec/core/callback_scope.md +++ b/docs/spec/core/callback_scope.md @@ -35,11 +35,11 @@ runs, and the natural spelling is silently wrong: completion.then([this](GetBoardResult r) { /* `this` may be long gone */ }); ``` -Correctness used to depend on every author independently remembering a +Without it, correctness depends on every author independently remembering a three-part incantation: declare a token member (last!), capture its weak form, re-check it before touching `this`. Forgetting compiles fine — which is the -hazard. Issue #137 was a real `stack-use-after-scope` write produced exactly -this way, invisible in every unsanitised build. +hazard, and the `stack-use-after-scope` write it produces is invisible in every +unsanitised build. Liveness is only half of it. Two different situations must both suppress delivery: @@ -216,8 +216,9 @@ cannot. It is normative. This is what makes a token the wrong tool for gating a **call into an object whose destruction it observes**, as opposed to gating *delivery of a callback*. A refused callback costs nothing when the check is stale; a member call made a - few instructions after a stale `Active` runs on destroyed memory. Issue #486 - was exactly that, in `~BridgeHandler`. Where a call has to be gated, the caller + few instructions after a stale `Active` runs on destroyed memory — + `~BridgeHandler` is the call site where that applies. Where a call has to be + gated, the caller needs something that *holds* the answer for the duration of the call — see `bridge::detail::BridgeLifetime` in [bridge.md](bridge.md), which pairs a `shared_mutex` with the flag so check-then-call is one step and the destructor @@ -268,8 +269,8 @@ statement. `morph::flows::FlowSession` does exactly this. | Decision | Rationale | |---|---| -| **Composition, not inheritance** | A base class (`HasLifetime`, tried in #150 and closed) is a requirement on every consumer's hierarchy — impossible or costly for `QObject`s, aggregates, and types that already have a base. A member composes with all of them, and non-adopting code is untouched. | -| **A distinct type, not `std::stop_token` alone** | `std::stop_token` covers requirement (2) but not (1): destroying a `stop_source` does not request stop, so a bare stop token says nothing about liveness. The verb names deliberately mirror `stop_source`/`stop_token` so a C++20 reader recognises the shape, and so #116's work-side cancellation can share this vocabulary rather than growing a second one. | +| **Composition, not inheritance** | A base class (a `HasLifetime`) is a requirement on every consumer's hierarchy — impossible or costly for `QObject`s, aggregates, and types that already have a base. A member composes with all of them, and non-adopting code is untouched. | +| **A distinct type, not `std::stop_token` alone** | `std::stop_token` covers requirement (2) but not (1): destroying a `stop_source` does not request stop, so a bare stop token says nothing about liveness. The verb names deliberately mirror `stop_source`/`stop_token` so a C++20 reader recognises the shape, and so a work-side cancellation facility could share this vocabulary rather than growing a second one. | | **Not named `CallbackContext`** | `morph::session::Context` already exists and means something entirely different (authenticated principal, token, request id). Two unrelated "Context" types in one framework is a readability tax. `Scope`/`Token` also matches the `std::stop_source`/`stop_token` pairing. | | **Fail-closed default token** | An unbound token suppressing is a visible functional bug (a callback that did not fire); an unbound token admitting is a use-after-free. The type exists to make that trade. | | **Three-way `status()` rather than a single `bool`** | Liveness and stop are genuinely different facts. Collapsing them is what made the hand-rolled `weak_ptr` idiom unable to express "alive but cancelled" in the first place. | @@ -282,9 +283,9 @@ statement. `morph::flows::FlowSession` does exactly this. ## Out of scope - **Cancelling the work.** This gates *delivery* of a result nobody wants; it - does nothing to the work still in flight producing it. That is issue #116's - half of the story, and the two are meant to end up one vocabulary — if #116 - lands, `requestStop()` is its natural upstream trigger. + does nothing to the work still in flight producing it. Nothing in the + framework offers work-side cancellation; if it ever does, `requestStop()` is + its natural upstream trigger and the two should share one vocabulary. - **Interop with `std::stop_token`.** Constructing a `CallbackToken` from an externally supplied `std::stop_token` (so callbacks tie into an existing cancellation tree) is a deliberate future extension, not present today. @@ -306,8 +307,8 @@ statement. `morph::flows::FlowSession` does exactly this. belongs to, and the self-join deadlock family the no-block-until-drained decision avoids. - [`executor.md`](executor.md) — `IExecutor`; `morph::qt::QtExecutor`'s own - `_alive` token (issue #151) is the adjacent-but-distinct case: *the executor* - going away, rather than the receiver. + `_alive` token is the adjacent-but-distinct case: *the executor* going away, + rather than the receiver. - [`workflows_navigation.md`](../forms/workflows_navigation.md) — `morph::flows::FlowSession`, the second in-framework adopter and the worked example of the "teardown that pumps" escape hatch. diff --git a/docs/spec/core/completion.md b/docs/spec/core/completion.md index 72402667c..a96908b82 100644 --- a/docs/spec/core/completion.md +++ b/docs/spec/core/completion.md @@ -69,19 +69,21 @@ with that instead, so `ready == true` always implies exactly one of `value` or `error` is engaged. That substitution closes a defect rather than tidying an edge case. Storing the -null set `ready` while leaving `error` falsy, and neither attach could act on -the result: `attachOnError` tests `ready && error`, `attachThen` tests `ready && -value`, so both fell through and a handler attached afterwards was neither -fired nor queued — the completion was dead in both directions for every later -caller, and silently, since `attachOnError` sets `onErrAttached` on entry and so -suppressed the destructor's orphan logger too. A handler attached *before* such -a settlement did fire, but with a null `exception_ptr`, which is undefined -behaviour to `std::rethrow_exception` — the idiomatic handler body, this file's -own orphan logger included. The guard lives here rather than at any one producer -because `Completion::Promise::reject()` is public and around ten sites -forward an `exception_ptr` through untouched (`.onError([state](auto e) { +null would set `ready` while leaving `error` falsy, and neither attach could +then act on the result: `attachOnError` tests `ready && error`, `attachThen` +tests `ready && value`, so both fall through and a handler attached afterwards +is neither fired nor queued — the completion is dead in both directions for +every later caller, and silently, since `attachOnError` sets `onErrAttached` on +entry and so suppresses the destructor's orphan logger too. A handler attached +*before* such a settlement does fire, but with a null `exception_ptr`, which is +undefined behaviour to `std::rethrow_exception` — the idiomatic handler body, +this file's own orphan logger included. The guard lives here rather than at any +one producer because `Completion::Promise::reject()` is public and around ten +sites forward an `exception_ptr` through untouched (`.onError([state](auto e) { state->setException(e); })`), so a per-producer guard would leave every other -one able to reintroduce it. See issue #347. When one or more callbacks are already registered +one able to reintroduce it. + +When one or more callbacks are already registered (via `attachThen` / `attachOnError`), a fire-once closure invoking every registered callback, in attachment order, is built under the lock and posted to the executor outside the lock, so no callback ever runs under the mutex. The @@ -130,8 +132,8 @@ stored value in place, and neither copies nor moves it: **The value is observed, never consumed.** No dispatch path can move out of `value`, so a `then()` attached after a set-after-attach dispatch already ran -still fires against the genuine result rather than a moved-from husk (morph#520; -see [Failure modes](#failure-modes)), and no handler in a fan-out can leave a +still fires against the genuine result rather than a moved-from husk (see +[Failure modes](#failure-modes)), and no handler in a fan-out can leave a husk for its siblings. A handler that wants to consume takes `T` **by value** and moves out of its own copy. Errors behave the same way for a different reason — an `exception_ptr` is a refcounted handle, cheap to copy, and is copied for every @@ -178,20 +180,19 @@ Settling itself moves `T` exactly twice for a prvalue argument — into `resolve`'s by-value parameter, then into `setValue`'s, then into `value`, with the first elided — and dispatch adds none. -This replaces a budget of **N + 2M** copies per settle (N handlers attached -before settling, M after) in which `T` was additionally forced to be -copy-constructible: `onOk` was erased as `std::function`, so every -handler was charged a copy whether or not it wanted one, and `attachThen`'s -fire-now path copied `*value` into a local and then captured that local *by -copy* before moving it into the handler — two copies where the handler asked -for at most one. See morph#553. +The obvious alternative — erasing `onOk` as `std::function` — costs +**N + 2M** copies per settle (N handlers attached before settling, M after) and +additionally forces `T` to be copy-constructible. Every handler is charged a +copy whether or not it wants one, and a fire-now path under that erasure has to +copy `*value` into a local and capture that local *by copy* before moving it +into the handler: two copies where the handler asked for at most one. **What this costs.** A cheap `T` pays a little more per settle: the dispatch closure holds a `shared_ptr` to the state and so pays an atomic refcount pair -where it used to copy a small value. Against the JSON encode/decode — and often +rather than a copy of a small value. Against the JSON encode/decode — and often a socket write — that surrounds a settle, that is noise, and it buys a large `T` its per-handler copies back. It is recorded here rather than hidden, because it -is a real regression on the cheap case. +is a real cost on the cheap case. **The large-`T` win requires `const T&` handlers.** The handler signature is the lever a caller pulls, which is why it is documented as contract rather than left @@ -265,7 +266,7 @@ backends do internally (see [Shared state](#shared-state--completionstatet)) — useful for test code (or any caller outside the framework's own producer code) that needs a `Completion` it can resolve or reject on demand, without a full `Bridge`/`IBackend` round trip and without ever naming -`morph::async::detail::CompletionState` (issue #55). +`morph::async::detail::CompletionState`. ```cpp auto [completion, promise] = morph::async::Completion::makeSettleable(&exec); @@ -322,10 +323,10 @@ throw — they are silent by construction. success handlers (`onOk`) and a `std::vector` of error handlers (`onErr`). A second, third, ... `then()` (or `onError()`) attached while the state is not yet ready is *appended*, not swapped in — every handler attached before - readiness runs when the result arrives, in the order it was attached. This - closes the earlier "last-writer-wins" foot-gun (issue #59), where a second - `onError()` on the same still-pending `Completion` silently discarded the - first handler and, because `onErrAttached` was still set, suppressed the + readiness runs when the result arrives, in the order it was attached. A + single-slot field instead would be a "last-writer-wins" foot-gun: a second + `onError()` on the same still-pending `Completion` would silently discard the + first handler and, because `onErrAttached` is set on attach, suppress the orphan logger too — losing the error's diagnostic entirely. - **Mismatched attach on a ready state is a silent no-op.** `then()` on a state @@ -338,12 +339,11 @@ throw — they are silent by construction. Both arms silently doing nothing is *only* safe because a ready state always has exactly one of `value`/`error` engaged, so at most one arm can mismatch. - A `ready` state with neither engaged made **both** arms no-ops, and the - completion could then never resolve for anybody — which is what - `setException(nullptr)` used to produce before it was made to substitute - (issue #347, and see [Setting a value or - exception](#setting-a-value-or-exception)). That state is now unreachable, - and this bullet depends on it staying so. + A `ready` state with neither engaged would make **both** arms no-ops, and the + completion could then never resolve for anybody. `setException(nullptr)` + substituting a `std::runtime_error` is what keeps that state unreachable (see + [Setting a value or exception](#setting-a-value-or-exception)); this bullet + depends on it staying so. - **Null-executor error drop, but no silencing.** With `cbExec == nullptr`, any attached or pending error handlers are never delivered — there is no executor @@ -362,7 +362,7 @@ throw — they are silent by construction. [Shared state](#shared-state--completionstatet)), so no fire-now dispatch consumes it and a handler taking `const T&` pays nothing for it. This holds regardless of which dispatch path originally delivered the value: `value` is - never moved out of (morph#520, morph#553 — see + never moved out of (see [Value-handling contract](#value-handling-contract)), so a `then()` attached after a set-after-attach dispatch fires against the same genuine result the earlier handlers saw, not a moved-from husk. @@ -409,7 +409,7 @@ with the caller; `cancel()` releases the callback immediately but leaves the underlying browser timer to elapse harmlessly rather than clearing it; and `cancel()` there really does mean "no callback runs after this returns", whereas the threaded build's `cancel()` returns while an *already-started* -callback goes on running on the scheduler thread (morph#620). A caller that +callback goes on running on the scheduler thread. A caller that must work in both builds gets the weaker of the two: every scheduled callback has to stay safe to run after its own `cancel()`, which the deadline callback here does by settling a write-once `CompletionState` it holds a `shared_ptr` @@ -588,8 +588,7 @@ the two calls a backend actually makes on the completion it produced. would turn a `bad_alloc` into a `std::terminate` that today it is not. See [`backend.md`, `IBackend::executeInto`](backend.md#executeinto--settling-the-callers-own-completion) and -[`bridge.md`, "`BridgeSink`"](bridge.md#bridgesink--the-typed-state-the-backend-settles) -(morph#572, Part B). +[`bridge.md`, "`BridgeSink`"](bridge.md#bridgesink--the-typed-state-the-backend-settles). ## `morph/core/async.hpp` — the cheap include @@ -600,7 +599,7 @@ them — it declares nothing of its own, so including it is exactly equivalent t including all four. It exists because the obvious header to reach for is `bridge.hpp`, and that -costs roughly three times as much. Measured on `master` @ c6f6d953, clang +costs roughly three times as much. Measured with clang 22.1.8, `-O2 -fsyntax-only`, one translation unit per header, best of three: | header | CPU s | preprocessed lines | @@ -612,9 +611,9 @@ costs roughly three times as much. Measured on `master` @ c6f6d953, clang | `core/bridge.hpp` | 3.76 | 267,827 | The whole async surface costs what one of its headers costs, because they -already share almost all of their own includes. See morph#573, step 4, and +already share almost all of their own includes. See [`journal.md`, "Why the codec is a separate header"](../journal/journal.md#why-the-codec-is-a-separate-header) -for the other half of that step. +for the same argument applied to the journal codec. ## Design decisions @@ -628,9 +627,9 @@ for the other half of that step. | Move-only handle | **`Completion` is move-only, `CompletionState` is shared via `shared_ptr`** | The handle is owned by one consumer at a time; the shared state is owned jointly by the producer and any consumer that has moved the handle. | | Empty completion | **Null state pointer makes `then`/`onError` no-ops** | Default-constructed `Completion` is a safe placeholder that never signals. | | Value handling on dispatch | **Both paths read `*value` in place; neither copies nor moves it** | Handlers are erased as `std::function` and the dispatch closures capture `shared_from_this()`, so the copy budget is exactly one per by-value handler and zero per `const T&` handler, whenever it attached. `value` is never consumed, so a `then()` attached after settling still sees the genuine result, and `T` need only be move-constructible. See [Value-handling contract](#value-handling-contract). | -| Handler fan-out | **`onOk`/`onErr` are `std::vector`s, appended to on each attach** | Fixes issue #59: a second `onError()` (or `then()`) on the same still-pending `Completion` used to silently replace the first handler in a single-slot field. Composing (invoking every attached handler, in order) matches the mental model of an observer list and is what most call sites composing behavior via repeated attach actually expect. | +| Handler fan-out | **`onOk`/`onErr` are `std::vector`s, appended to on each attach** | A single-slot field would let a second `onError()` (or `then()`) on the same still-pending `Completion` silently replace the first handler. Composing (invoking every attached handler, in order) matches the mental model of an observer list and is what call sites composing behaviour via repeated attach expect. | | Per-handler exception isolation | **Each composed handler invocation is wrapped in its own `try`/`catch (...)`, logged via `logError` and swallowed** | Fan-out means every attached handler should get its turn regardless of what an earlier one does. Without per-handler isolation, one throwing handler would unwind the whole posted closure and silently skip every handler attached after it — turning a single misbehaving consumer into an outage for unrelated ones sharing the same `Completion`. | -| Public settleable-promise seam | **`Completion::Promise`, reachable only via `makeSettleable()`** | Fixes issue #55: test code needing a `Completion` it can resolve/reject on demand had no seam except reaching into `morph::async::detail::CompletionState` directly. `Promise`'s constructor is private and `friend`ed only to `Completion`, so `detail::CompletionState` never has to appear in a caller's own code. | +| Public settleable-promise seam | **`Completion::Promise`, reachable only via `makeSettleable()`** | Without it, test code needing a `Completion` it can resolve/reject on demand has no seam except reaching into `morph::async::detail::CompletionState` directly. `Promise`'s constructor is private and `friend`ed only to `Completion`, so `detail::CompletionState` never has to appear in a caller's own code. | ## Limitations @@ -654,7 +653,7 @@ future/promise or a monadic async type. Its scope is narrow by design: **Delivery**, by contrast, *can* be stopped — see [Lifetime and stop gating](#lifetime-and-stop-gating). The distinction is sharp and deliberate: a `CallbackScope` says "do not hand me this result", - never "stop producing it". Work-side cancellation is issue #116. + never "stop producing it". Work-side cancellation is not offered at all. - **Single consumer handle, but multiple handlers per outcome.** The `Completion` handle itself is move-only — only one owner at a time — but each state's `onOk`/`onErr` are vectors, so repeated `then()`/`onError()` diff --git a/docs/spec/core/executor.md b/docs/spec/core/executor.md index 444e34b4f..4d4e7d2c1 100644 --- a/docs/spec/core/executor.md +++ b/docs/spec/core/executor.md @@ -148,8 +148,8 @@ three `Completion` objects per dispatched action, each settled from *inside* the previous one's delivered callback, so a caller waiting only on its own top-level completion can observe "done" while an intermediate post is still queued; when that stale event is finally pumped, its body calls `post()` for -the next link. Before the guard this segfaulted ordinary uninstrumented -builds, not merely sanitizer runs (morph#127). +the next link. Without the guard this segfaults ordinary uninstrumented +builds, not merely sanitizer runs. Dropping is the correct outcome rather than the lesser evil: a chain being torn down has nobody left to observe its result, and @@ -278,7 +278,7 @@ bookkeeping: the next queued task for that key still runs. The destructor waits for all in-flight tasks to complete (`_inFlight == 0`) before destroying the strand map. -**Testing per-model ordering without naming `StrandExecutor`/`ModelId` (issue #55).** +**Testing per-model ordering without naming `StrandExecutor`/`ModelId`.** `RemoteServer` (see `backend.md`) owns a `StrandExecutor` internally, but every task it ever dispatches — the top-level `handle()` post and the internal per-model strand dispatch alike — funnels through the single `IExecutor` the @@ -345,50 +345,49 @@ combined `{_mapMtx, strand->mtx}` lock. Live memory therefore tracks the set of *currently active* models rather than every model ever seen — there is no per-model registration to leak. -**The cost was allocation churn.** A model posted to serially — one action at -a time, each waited out — never has a task queued at the instant the previous -one finishes, so it never keeps a strand: every dispatch missed in the map and -rebuilt the map node and the `Strand`. Measured on `7a343e6f` with +**The cost it would otherwise carry is allocation churn.** A model posted to +serially — one action at a time, each waited out — never has a task queued at +the instant the previous one finishes, so it never keeps a strand: every +dispatch misses in the map, and a naive implementation rebuilds the map node +and the `Strand` each time. Measured with `tests/bench/bench_dispatch_allocations.cpp` (see [testing_strategy.md](../testing_strategy.md)), x86-64 Linux, GCC 16.2.1 / -libstdc++, `-O2`, that came to **4 allocations and 760 of the 1990 bytes** a -local `execute` round trip cost — 38% of the bytes, for a strand that is -rebuilt and thrown away. 576 of those bytes were not the strand at all but +libstdc++, `-O2`, that comes to **4 allocations and 760 of the 1990 bytes** a +local `execute` round trip costs — 38% of the bytes, for a strand that is +rebuilt and thrown away. 576 of those bytes are not the strand at all but `std::queue`'s `std::deque` eagerly allocating a node map and a 512-byte first -buffer in its default constructor. Replacing that container with -`PendingQueue`, which holds the head task inline, cut the strand's share to **2 -allocations and 152 bytes** and the whole round trip to 18.9 allocations / -1396 bytes (morph#660). +buffer in its default constructor, which is why the pending queue is +`PendingQueue`, holding the head task inline: that alone cuts the strand's +share to **2 allocations and 152 bytes** and the whole round trip to 18.9 +allocations / 1396 bytes. **The remaining two allocations — the map node and the `Strand` itself — are -recycled rather than removed (morph#670).** They looked inherent to the erase: -removing them appeared to mean keeping the slot alive across the drain, which -would trade the churn for a per-model entry nothing reclaims, since -`StrandExecutor` has no deregistration hook. That framing turned out to be -avoidable. The entry still leaves the map at exactly the same moment, under -exactly the same locks; the drain simply calls `extract` instead of `erase` and -parks the detached node in a single-slot `_spare` member, and the next -`post()` that misses re-keys that node and inserts it back. The map is still -bounded by the removal — `_spare` holds **at most one** node, is guarded by -`_mapMtx` like the map itself, and is freed with the executor. +recycled rather than removed.** Removing them means keeping the slot alive +across the drain, which trades the churn for a per-model entry nothing +reclaims, since `StrandExecutor` has no deregistration hook. Recycling avoids +that trade: the entry leaves the map at exactly the same moment, under exactly +the same locks; the drain calls `extract` instead of `erase` and parks the +detached node in a single-slot `_spare` member, and the next `post()` that +misses re-keys that node and inserts it back. The map stays bounded by the +removal — `_spare` holds **at most one** node, is guarded by `_mapMtx` like the +map itself, and is freed with the executor. Reusing the parked node's `Strand` object is guarded additionally by `use_count() == 1`: the recycled node is then the only owner, so no strand task can still reach the object and reusing it is indistinguishable from constructing a new one. When that guard fails — a finishing strand lambda still holds its `shared_ptr` when the next `post()` looks — a fresh `Strand` is -constructed exactly as before and only the node is recycled. To make the guard +constructed and only the node is recycled. To make the guard usually hold, the strand lambda drops its `shared_ptr` immediately after the drain block rather than at its own destruction; nothing after that point touches the strand. That timing affects *whether* the object is recycled, never whether the recycling is safe. -Re-measured on `7d4ca453` (this change's base) with the same instrument, -x86-64 Linux, **clang 22.1.8 / libstdc++ 16.2.1, Release**: the round trip went -from **18.90 allocations / 1394.8 bytes** to **16.95 / 1244.6** — the full 2 -allocations and ~150 bytes the strand had left. Six alternating runs of each -binary; spread within 0.1 allocations and 2 bytes per call. The magnitude is -libstdc++-specific, as it was for morph#660. +Measured with the same instrument, x86-64 Linux, **clang 22.1.8 / libstdc++ +16.2.1, Release**: recycling takes the round trip from **18.90 allocations / +1394.8 bytes** to **16.95 / 1244.6** — the full 2 allocations and ~150 bytes +the strand had left. Six alternating runs of each binary; spread within 0.1 +allocations and 2 bytes per call. The magnitude is libstdc++-specific. ## Thread safety diff --git a/docs/spec/core/file_io_ops.md b/docs/spec/core/file_io_ops.md index b64659183..f81ada463 100644 --- a/docs/spec/core/file_io_ops.md +++ b/docs/spec/core/file_io_ops.md @@ -9,8 +9,8 @@ member is a `std::function` defaulting to the real syscall/stdlib call it stands in for. The header also hosts four **free functions** that are not part of the struct -and are not injectable — shared file-handling logic that had been duplicated -across the two classes, or that exists to paper over a platform difference: +and are not injectable — shared file-handling logic common to both classes, +or logic that exists to paper over a platform difference: `wideFtell`, `positionAtEnd`, `rollBackShortWrite`, `repairTornTail`, plus the `classifyDirectorySync` helper and its `DirectorySync` enum. See [Free functions](#free-functions). @@ -31,9 +31,7 @@ run when a real OS-level file-I/O call fails partway through an otherwise-successful operation — disk full, a file descriptor closed underneath, a permission change racing an exact window between two library calls. None of those are reachable from a portable unit test without a way -to fail one specific call on demand (see `LASTRADA-Software/morph#97`, -which requested exactly this for `FileActionLog`; `FileOfflineQueue` has the -identical gap). +to fail one specific call on demand; the gap is identical in both classes. `FileIoOps` is that seam. A test constructs one, overrides the one member it wants to fail (optionally gated behind a `std::shared_ptr` or a call @@ -57,7 +55,7 @@ underlying call: | `resizeFile` | `std::filesystem::resize_file` | `void(const std::filesystem::path&, uintmax_t, std::error_code&)` | | `syncPath` | `open(dir, O_RDONLY\|O_DIRECTORY)` + `fsync` (POSIX); no-op on Windows | `int(const std::filesystem::path&)` | -`syncPath` (morph#532) commits a directory's own metadata — a new or +`syncPath` commits a directory's own metadata — a new or renamed entry within it — to durable storage; `fsync` on a *file* makes only that file's data durable, not the directory entry that names it. Both `FileActionLog` and `FileOfflineQueue` call it after every directory @@ -65,7 +63,7 @@ mutation (file creation at construction, `rotate()`'s seal rename, and `compact()`'s rewrite-in-place rename), surfacing a failure rather than swallowing it — see `docs/spec/journal/journal.md` and `docs/spec/offline/offline.md` for the call sites. `rollBackShortWrite()` -(morph#530) is the other seam-driven addition in this header: on a short +is the other seam-driven member of this header: on a short `fwrite`, it truncates the file back to its pre-write length using the same injectable `resizeFile`/`fflush` this struct provides, so a partial write never sits where the next append would otherwise merge with it. @@ -99,7 +97,7 @@ resize_file` to an offset **beyond** the current size does not shrink the file; it **grows** it, padding with NUL bytes, and a later flush then appends the buffered record after that padding. The result is a NUL-bearing *interior* line that the caller's reader rejects for the life of the file — the exact bricking -morph#530 exists to prevent, manufactured by the rollback meant to prevent it. +the rollback exists to prevent, manufactured by the rollback itself. (Measured: `ftell` 30 against an on-disk size of 10, `resize_file(30)` yielding a 30-byte file, and a final 50-byte file of data + 20 NULs + the flushed record.) @@ -123,8 +121,8 @@ Measured against a queue, with the write short and the rollback's own flush failing (one full disk produces both), then space freed and one more enqueue succeeding: the merged line makes the next open throw a raw parse error instead of loading, so every record in the file — including ones written long before the -failure — becomes unreachable. That is the same bricking morph#530 exists to -prevent, reached *through* the rollback rather than around it. Both callers +failure — becomes unreachable. That is the same bricking, reached *through* +the rollback rather than around it. Both callers therefore latch the `torn` result and throw from every subsequent `append()`/`writeLine()`, which keeps the partial record trailing and so recoverable at the next open. @@ -190,4 +188,3 @@ already have their own well-defined thread-safety. own design, including the branches this seam closes. - [`docs/spec/offline/offline.md`](../offline/offline.md) — `FileOfflineQueue`'s own design, including the identical class of branch this seam closes. -- `LASTRADA-Software/morph#97` — the issue that requested this seam. diff --git a/docs/spec/core/registry.md b/docs/spec/core/registry.md index 60201cf26..ae3d2e4b0 100644 --- a/docs/spec/core/registry.md +++ b/docs/spec/core/registry.md @@ -107,7 +107,7 @@ reproduction (a header invoking `BRIDGE_REGISTER_MODEL`/`BRIDGE_REGISTER_ACTION` included by `tu_a.cpp` and `tu_b.cpp`, both resolving the model through `ModelRegistryFactory`) compiles, links and runs clean with **zero diagnostics** on Clang 22.1.8 and GCC 15.3.0, and on Clang 22.1.3 and -MSVC 19.51 in morph#231. +MSVC 19.51. Item (2) is what actually differs, and the cost is small. The anonymous namespace makes each initialiser object internal to its TU, so the model is @@ -550,19 +550,19 @@ Maps `(modelId, actionId)` pairs to type-erased runner functions. Used by **One map, looked up by view.** Everything registered under a pair lives in one `ActionEntry` record — `runner`, `coalesce`, `schema`, `describe` — in a single `unordered_map, ActionEntry, PairKeyHash, PairKeyEqual>`. -Two things follow, both of them morph#572 Part C: +Two things follow: - *No key is built to look one up.* `PairKeyHash` and `PairKeyEqual` are transparent, so `find` takes a `detail::PairKeyView` — a pair of - `string_view`s — directly. Constructing the stored key instead cost two - `std::string`s per lookup, and reached the heap for any id past the + `string_view`s — directly. Constructing the stored key instead costs two + `std::string`s per lookup, and reaches the heap for any id past the 15-character SSO buffer. **Measured** with `morph_bench_alloc` - (clang 22.1.8 / libstdc++ 16.2.1): for a pair whose ids both exceed the - buffer, **2.00 → 0.00** allocations per lookup; for a pair whose ids both fit - it, **0.00 → 0.00** — which is the part morph#529 flagged as unverified and - is now measured. morph's real ids straddle that boundary, so the saving is - real but id-dependent: `"BenchAlloc_Model"` (16 characters) allocated, - `"BenchAlloc_Ping"` (15) did not. + (clang 22.1.8 / libstdc++ 16.2.1), stored key against transparent lookup: for + a pair whose ids both exceed the buffer, **2.00 → 0.00** allocations per + lookup; for a pair whose ids both fit it, **0.00 → 0.00**. morph's real ids + straddle that boundary, so the saving is real but id-dependent: + `"BenchAlloc_Model"` (16 characters) allocates, `"BenchAlloc_Ping"` (15) does + not. - *The four sub-maps cannot go out of step.* They were filled together by `registerAction` and could not diverge in practice, but nothing said so. `RemoteServer::handle` also reached three of them per request; that is now @@ -857,7 +857,7 @@ diagnosable failure. Each of the three builds `std::string` keys and grows a map, so each can throw `std::bad_alloc` through a `noexcept` boundary and call `std::terminate`. That is recorded in [Failure modes](#failure-modes) and is **deliberate, not an -oversight** (morph#698). +oversight**. The only caller of any of them is the initialiser of a namespace-scope `const bool` that `BRIDGE_REGISTER_MODEL` / `BRIDGE_REGISTER_ACTION` emit. An @@ -1211,8 +1211,8 @@ correctly under `MORPH_CLIENT_ONLY`. | `ModelFactory::create` attaches the default log | **Single construction path for all topologies** | "Set the log once in `main()`" works uniformly across local and remote topologies. Callers that need a specific identity call `attachActionLog` again afterward. | | `setOutboxManaged` opt-out | **Suppress `recordIfAttached`, not `hasActionLog()`** | A store-backed model that logs inside its own transaction (see `journal.md`'s transactional outbox) must stop the framework's auto-append without losing "a log is attached" as a fact holders can still query. | | `coalesce` defaults to `false` | **Every execution is a distinct, permanent fact** | The right default for anything resembling a business event. Only actions where only the latest occurrence should survive a checkpoint (e.g. a form-field edit fired repeatedly via `morph::flows::FlowSession::set`) opt in. | -| `ActionDispatcher` keeps one record per pair, not four maps | **`ActionEntry` in a single `unordered_map`** | The four sub-maps were keyed identically and filled by one function, so the lockstep was real but unstated; and `RemoteServer::handle` reached three of them per request. One record makes the invariant structural and the three reads one table each (morph#572, Part C). | -| Enforcing the "no registration after `main`" precondition | **A debug-build latch and an `assert`, not a mutex** | The constraint was documented and unenforceable; a violation's only symptom was intermittent map corruption. A latch closed by the first singleton read costs nothing in a release build and turns the `dlopen` scenario into an abort with a message. Synchronising the registries instead would put a lock on a per-request read path to legalise a startup-only operation — a trade nobody has measured (morph#698). | +| `ActionDispatcher` keeps one record per pair, not four maps | **`ActionEntry` in a single `unordered_map`** | The four sub-maps were keyed identically and filled by one function, so the lockstep was real but unstated; and `RemoteServer::handle` reached three of them per request. One record makes the invariant structural and the three reads one table each. | +| Enforcing the "no registration after `main`" precondition | **A debug-build latch and an `assert`, not a mutex** | Documented alone the constraint is unenforceable, and a violation's only symptom is intermittent map corruption. A latch closed by the first singleton read costs nothing in a release build and turns the `dlopen` scenario into an abort with a message. Synchronising the registries instead would put a lock on a per-request read path to legalise a startup-only operation — a trade nobody has measured. | | `register*Once` stays `noexcept` | **Keep it, and record why** | The only caller is a namespace-scope initialiser, where an escaping exception already calls `std::terminate` ([basic.start.dynamic]). Removing `noexcept` changes nothing about an OOM at static init and adds a dead unwind path. See ["Why all three are `noexcept` while allocating"](#why-all-three-are-noexcept-while-allocating). | | Registry lookups are heterogeneous | **Transparent `PairKeyHash`/`PairKeyEqual`, `find(PairKeyView)`** | Every caller already holds `string_view`s; materialising the stored `pair` to hash it allocated for any id past the SSO buffer, measured at 2 allocations per lookup. The transparent hash routes both overloads through one `string_view` body so lookup and stored hashes cannot drift apart — a drift that would report a registered action as unknown with no diagnostic. | @@ -1242,10 +1242,9 @@ quiesced with respect to dispatch, before exposing them. ### The registration-phase latch -The constraint above was, until morph#698, documented and unenforced: nothing -in the tree detected a post-`main` registration, so a caller could violate it -silently and discover it as intermittent map corruption with no diagnostic -anywhere. +Documented alone, the constraint above is unenforced: nothing would detect a +post-`main` registration, so a caller could violate it silently and discover it +as intermittent map corruption with no diagnostic anywhere. `registry.hpp` now carries a one-way latch over the registration phase: @@ -1280,10 +1279,10 @@ of corrupting a map on a release build with no message at all. latch itself. `closeRegistrationPhase()` and `registrationPhaseClosed()` exist and work on both builds; only the *automatic* closing is conditional. - *The latch is not synchronisation.* It detects the violation; it does not - make the violating call safe. Option (b) of morph#698 — an actual mutex or - concurrent map on the registries — remains explicitly out of scope, because - the read path is per-request on the server and no cost measurement has been - taken for locking it. + make the violating call safe. The alternative — an actual mutex or concurrent + map on the registries — is explicitly out of scope, because the read path is + per-request on the server and no cost measurement has been taken for locking + it. The enforcement is proven rather than asserted, per `AGENTS.md`: `tests/test_registration_phase.cpp` `fork()`s a child that closes the latch and diff --git a/docs/spec/core/shared_instances.md b/docs/spec/core/shared_instances.md index 061aa1c5c..36e8ffd62 100644 --- a/docs/spec/core/shared_instances.md +++ b/docs/spec/core/shared_instances.md @@ -100,14 +100,13 @@ kinds qualify: The second exists because `examples/IMPLEMENTATION.md` rule 3 *requires* entity identity to be a per-entity strong id exposing `hasValue()`, so it joins the -forms palette as an empty-capable field. While `ModelKey` admitted only raw +forms palette as an empty-capable field. Were `ModelKey` to admit only raw scalars, those two rules could not both be obeyed: a rung following rule 3 -could not use `BRIDGE_MODEL_KEY`/`BRIDGE_KEY_FROM` at all, and three rungs -independently hand-wrote `ModelKeyTraits`/`ActionKeyTraits` instead — each -re-stating the `*id` unwrapping the macro exists to hide (morph#163). Those -three (kanban, ledger, lims) use the macros now; morph#183 deleted the -hand-written blocks, and with them the `*id` dereference of a possibly-empty -strong id that each one performed. +could not use `BRIDGE_MODEL_KEY`/`BRIDGE_KEY_FROM` at all, and would have to +hand-write `ModelKeyTraits`/`ActionKeyTraits` instead — each copy re-stating +the `*id` unwrapping the macro exists to hide, and each dereferencing a +possibly-empty strong id in the process. The three rungs whose entities carry +strong ids (kanban, ledger, lims) use the macros. A strong id encodes as **whatever it wraps**, so it shares a directory entry with the raw key of the same value; the directory stays one map keyed on @@ -371,18 +370,15 @@ verb-agnostic: `register` (shared or not) and `attach` all reply `ok` with a the synchronous methods' own degrade-to-private behaviour rather than inventing new semantics. -Until morph#571 this was expressed as two *optional* `IBackend` virtuals -(`registerModelSharedAsync`/`attachModelAsync`) returning `bool`, beside two -more for the private bind and the promote. They are gone; the reasoning that -survived them is in [backend.md](backend.md), "What was wrong with the old -shape". +Why a single dispatch virtual rather than one optional virtual per verb: see +[backend.md](backend.md), "Why one bind virtual and not four". **Why this exists.** `registerModelShared`/`attachModel` are synchronous, so on a wire backend they block in a nested `QEventLoop`, which a WASM main thread -cannot spin at all. Before this, the *first* payload-keyed action a WASM client -executed — the very shape a keyed screen is built on — aborted the page. See -`examples/LADDER.md`, "Framework prerequisites" #1, for the rung-3 (`polls`) -scenario that motivated closing this. +cannot spin at all. Without the async path, the *first* payload-keyed action a +WASM client executes — the very shape a keyed screen is built on — aborts the +page. See `examples/LADDER.md`, "Framework prerequisites" #1, for the rung-3 +(`polls`) scenario this serves. **What callers see.** Nothing, by design. `BridgeHandler::execute()`'s signature and its documented contract are unchanged, including the promise that diff --git a/docs/spec/core/wire.md b/docs/spec/core/wire.md index 402930386..7a4aabcce 100644 --- a/docs/spec/core/wire.md +++ b/docs/spec/core/wire.md @@ -106,8 +106,9 @@ turn it into an `"err"` reply rather than propagating it (see `Envelope` is a union of all kinds: an `ok` reply uses three of its thirteen members and a `deregister` request uses two. glaze writes every member of a -struct it is handed, so before morph#524 a minimal `ok` reply carrying an -8-byte payload was **255 bytes, 213 of them fields the kind does not use**: +struct it is handed, so without the omission rule below a minimal `ok` reply +carrying an 8-byte payload is **255 bytes, 213 of them fields the kind does not +use**: ``` {"kind":"ok","callId":7,"typeId":"","contextKey":"","primary":"","shared":false, @@ -222,12 +223,12 @@ one of the reflected keys — a rename would otherwise make glaze report that emptiness test would be silently dropped from every envelope whose other session fields are empty. -What this does **not** address is the other half of morph#524: `body` still -holds JSON that is escaped as a JSON string, so a payload is expanded on the -way out and re-parsed on the way in. That change (`glz::raw_json`) *is* a -retype, does need a version bump, and interacts with the +What omitting defaults does **not** address is `body`: it still holds JSON +escaped as a JSON string, so a payload is expanded on the way out and re-parsed +on the way in. Carrying it as `glz::raw_json` instead *is* a retype, does need a +version bump, and interacts with the [`body` double-parse hazard](#the-body-double-parse-hazard) and the size cap -that exists to bound it. It is deliberately not in this change. +that exists to bound it — which is why the two are kept separate. ### Control bytes in string fields @@ -299,10 +300,10 @@ every `encode` at runtime. The arm is still unreachable through any `Envelope` That left a branch guarding a real invariant permanently uncovered. `WireCodecOps` closes it the way this repository already closes the identical -problem for file I/O (`morph::core::FileIoOps`, added for -LASTRADA-Software/morph#97): an injectable strategy whose single member -defaults to the real call, so a default-constructed `WireCodecOps` is -byte-for-byte the previous behaviour, and a test injects a failing one. +problem for file I/O (`morph::core::FileIoOps`): an injectable strategy whose +single member defaults to the real call, so a default-constructed +`WireCodecOps` is byte-for-byte the real codec, and a test injects a failing +one. `defaultWireCodecOps()` is a function-local static rather than a default-constructed temporary in the signature: `encode` runs on every outbound @@ -439,11 +440,11 @@ no version check, `protocolVersion` stays `0` on every envelope. `morph::forms::schemaJson()` renders one action as a JSON Schema document — properties, a derived `required` array, `x-decimalPlaces`, `x-rules`, layout -hints. It is a *compile-time* function over a reflected action struct, so until -the `"schemas"` kind existed the document was reachable only from a caller -linked against the model's own C++. A WASM page, a third-party client, or a -scenario runner that wants to name the offending field *before* a round trip -had no way to ask (LASTRADA-Software/morph#234). +hints. It is a *compile-time* function over a reflected action struct, so +without the `"schemas"` kind the document is reachable only from a caller +linked against the model's own C++ — leaving a WASM page, a third-party client, +or a scenario runner that wants to name the offending field *before* a round +trip with no way to ask. ### The `"schemas"` control kind @@ -513,8 +514,8 @@ kept in step with it. ### Why this, and not a fingerprint exchange at `"hello"` -morph#207 proposes exchanging per-action fingerprints during the `"hello"` -handshake. The fingerprints are the same either way — this reuses +The obvious alternative is to exchange per-action fingerprints during the +`"hello"` handshake. The fingerprints are the same either way — this reuses `morph::model::payloadFingerprint()` rather than defining a wire-specific scheme — but the *carrier* is `"schemas"` for one reason: `"hello"` is deliberately unauthorized ("carries no `session` and is not authorized — @@ -602,7 +603,7 @@ skipped the mandated `kProtocolVersion` bump was accepted silently, because the lenient inner decode reads an unknown key as absent and an absent one as default-constructed. `validate()` cannot close that gap — it sees a zero-valued action and cannot tell "the client sent nothing" from "the client -sent a legitimate zero" (LASTRADA-Software/morph#207). +sent a legitimate zero". **What is mechanically checkable, and what is not.** Only the first bullet states a machine-readable predicate: *new fields must be optional, so an older @@ -727,7 +728,7 @@ client. | Decision | Choice | Why | |---|---|---| | Single struct vs. discriminated union | **One `Envelope` struct; every kind's fields are members of it** | The C++ shape is fixed and predictable; callers populate only what their kind needs. Avoids a tagged-union complexity that would add no benefit over a single struct with a `kind` string. | -| Shrinking the serialized form | **Omit members at their default, rather than reshaping `Envelope`** | The 255-byte minimal reply was a *serialization* problem, not a struct problem. Omitting defaults needs no `std::optional` members (which would change every call site's `env.typeId = ...`), no variant, and no `kProtocolVersion` bump — a default-initialising, unknown-key-tolerant decoder cannot tell the two forms apart. 255 B → 44 B on a minimal reply. See [Omitted default fields](#omitted-default-fields) (morph#524). | +| Shrinking the serialized form | **Omit members at their default, rather than reshaping `Envelope`** | The 255-byte minimal reply was a *serialization* problem, not a struct problem. Omitting defaults needs no `std::optional` members (which would change every call site's `env.typeId = ...`), no variant, and no `kProtocolVersion` bump — a default-initialising, unknown-key-tolerant decoder cannot tell the two forms apart. 255 B → 44 B on a minimal reply. See [Omitted default fields](#omitted-default-fields). | | `kind` as a string vs. enum | **`std::string`** | JSON naturally discriminates by string; avoids an enum-to-string mapping. The factory functions (`makeRegister`, etc.) ensure callers never set `kind` manually. | | `"execute"` has no factory | **No factory** | `"execute"` envelopes are typically constructed by higher-level APIs (`Client`, `RemoteServer`), not by end users. Adding a factory would be dead code at the wire layer. | | Factory functions are `inline` | **Header-only** | The entire wire module lives in the header. Wrapping each factory as a named function keeps construction safe (correct `kind`, no forgotten fields) without a separate compilation unit. | diff --git a/docs/spec/error_handling.md b/docs/spec/error_handling.md index 4d6988783..f8255b259 100644 --- a/docs/spec/error_handling.md +++ b/docs/spec/error_handling.md @@ -92,12 +92,11 @@ before any concurrency, and read unlocked thereafter (as **Single-shot result, composing callbacks.** The *result* (value or error) is single-shot — `setValue`/`setException` are no-ops once `ready`. But `then()` and `onError()` each **compose**: every handler attached while the state is not -yet ready is kept and fires, in attachment order, when the result lands. This -was fixed under issue #59 — `onOk`/`onErr` used to be single fields, so a -second `onError()` on the same still-pending `Completion` silently discarded -the first handler (and, because `onErrAttached` was still set from that second -call, suppressed the orphan logger too — losing the error entirely, not just -misdelivering it). +yet ready is kept and fires, in attachment order, when the result lands. Composition +is what makes this safe: were `onOk`/`onErr` single fields, a second +`onError()` on the same still-pending `Completion` would silently discard the +first handler and, because `onErrAttached` is set by that second call, suppress +the orphan logger too — losing the error entirely, not just misdelivering it. | Situation | Behavior | |---|---| diff --git a/docs/spec/forms/choice.md b/docs/spec/forms/choice.md index 036b567f0..0eb001dbc 100644 --- a/docs/spec/forms/choice.md +++ b/docs/spec/forms/choice.md @@ -162,13 +162,13 @@ aliases silently — rename the wire field to use it in a `Choice`. **The name has to vary, because glaze populates a `$defs` entry only once.** Its schema writer does `auto& def = defs[name_v]; if (!def.type) { … }`, so -when two instantiations shared the single name `"Choice"`, the second one was -skipped and `$ref`ed the *first* one's definition. An action holding a `bool` -picklist and an `int64_t` picklist described the int64 field as a boolean — +if two instantiations shared the single name `"Choice"`, the second one would be +skipped and `$ref` the *first* one's definition. An action holding a `bool` +picklist and an `int64_t` picklist would describe the int64 field as a boolean — and `DynamicForm.qml` resolves the `$ref`, reads `type`, and draws a checkbox for `"boolean"`. A generic validating client is misled the same way, since the document ships `additionalProperties: false` and a standard `required` array, -i.e. it is presented as validatable. That was morph#543. +i.e. it is presented as validatable. Two properties of the composed name are deliberate: @@ -311,7 +311,7 @@ dense id sequence collapses pairwise — two option rows reduce to the same `valueJson`, the combo box shows two entries the UI cannot tell apart, and the staleness guard matches happily against either. A sparse id usually rounds to a value naming no row at all, which either throws in the model or is stored as -garbage, depending on whether the action looks the id up (morph#190). +garbage, depending on whether the action looks the id up. Values a double *does* hold exactly — the overwhelmingly common case — remain ordinary JSON numbers, so nothing about the wire shape changes for them. diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index 89740d07a..4c259186a 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -325,25 +325,25 @@ forwards to inserts a default-constructed member for a missing key So the checking depends on the constness of the DOM, not on the spelling, and these walkers are mutating by construction. A mechanical `operator[]` → `at()` sweep over them would silence ~70 findings while changing a read into a write -on exactly the inputs the check warns about (morph#706). +on exactly the inputs the check warns about. `tests/test_forms_dom_access.cpp` asserts both halves — that `findMember` leaves the document byte-identical on a miss, and that `at()` on the pinned glaze does not — so a glaze release that gives `at()` real checked semantics turns that file red rather than leaving this rationale quietly stale. -The sites that still carry a standing +The sites that carry a standing `NOLINT(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)` are the -writes, and the directive now says so. +writes, and the directive says so. -**Not every read** (morph#714). `views.hpp` reads a *const* DOM, where +**Not every read is converted.** `views.hpp` reads a *const* DOM, where `operator[]` throws rather than inserts — a different and louder failure than -the mutating walkers', but still one the caller cannot see coming. Its reads -were converted with one deliberate exception, stated here because the -exception is the interesting part: **a read whose key is guaranteed present by -construction keeps its subscript.** Turning such a read into a null check adds +the mutating walkers', but still one the caller cannot see coming. Its reads are +checked with one deliberate exception, stated here because the exception is the +interesting part: **a read whose key is guaranteed present by construction keeps +its subscript.** Turning such a read into a null check adds a branch nothing can take — untestable code, and a branch-coverage allowlist entry someone must later write a justification for. That is a cost, not a -safety improvement, and it is the lesson morph#706 paid for. +safety improvement. Two reads of `rowDom["properties"]` are that exception. glaze's schema writer emits `"properties"` unconditionally for every reflectable aggregate, including @@ -368,8 +368,8 @@ now carry a converting a read moved the *write* beside it onto a changed line and `clang-tidy-diff` reports on changed lines. Those three are the first suppressions of this check in `views.hpp`, and each says in one word what it -is: a write. The other 18 writes are untouched and still unsuppressed, which is -the piecemeal bill morph#677 describes and does not try to settle here. +is: a write. The other 18 writes are unsuppressed, because only a changed line +is reported; converting the tree in one sweep is a separate job from this one. Counted a second way as well, through `tests/test_views.cpp`, a translation unit that actually *instantiates* the templates: the two measurements agree exactly, @@ -998,8 +998,8 @@ An enum **without** a `glz::meta` is refused at compile time. Without the declaration, glaze would emit a `$ref` to a `$defs` entry that is the six-way wildcard `{"type": ["number", "string", "boolean", "object", "array", "null"]}`, naming neither the enumerators nor even a single type — the shipped -`DynamicForm` drew that wildcard as a checkbox, reporting the form ready for a -value nobody chose (morph#392). `schemaJson()` now `static_assert`s on +`DynamicForm` draws that wildcard as a checkbox, reporting the form ready for a +value nobody chose. `schemaJson()` therefore `static_assert`s on `glz::glaze_enum_t` for every `enum class` member it reaches, so a rung that declares one without `glz::meta`/`glz::enumerate` fails to build rather than shipping a form that lies about being ready. @@ -1134,9 +1134,9 @@ not one, because only one of the two signals is universal: **target** on the signal being declared — `form.controller.optionsReceived !== undefined`, else `null` — so a controller that omits it is never connected to and the absence is not a warning. Without the split, every form instance - warned once about `onOptionsReceived` as soon as a conforming choiceless - controller was attached (morph#387), which forced any GUI test asserting "no - QML warnings" to tolerate that exact text. + warns once about `onOptionsReceived` as soon as a conforming choiceless + controller is attached, which forces any GUI test asserting "no QML warnings" + to tolerate that exact text. The gate is what makes the block optional, **not** `ignoreUnknownSignals`. A controller that does declare `optionsReceived` is connected to strictly, so a @@ -1427,7 +1427,7 @@ Display formatting is the renderer's duty; the wire stays canonical: — the conversion happens at the control edge only. **The locale facts travel as one aggregate, not as a row of positional - views** (morph#591): + views**: ```cpp struct NumericLocale { @@ -1453,16 +1453,16 @@ Display formatting is the renderer's duty; the wire stays canonical: defaulted member rather than a seventh parameter. Third, and the reason it is worth the churn: **the two edges take the same type**, so "these two must agree" is structural rather than a convention a caller can get half right — - which is the drift morph#591 and morph#599 both came from. There is + and that convention is exactly what the two edges drift apart on. There is deliberately no back-compatible positional overload: two spellings of one call - is how the edges drifted apart to begin with. The QML mirror takes the + is how they drift. The QML mirror takes the parallel shape, an object literal with the same member names, so the two mirrors stay structurally identical. Every member is defaulted to its `"C"`-locale spelling, so a caller that names none of them gets the identity transform in both directions. - **The digits are locale data too, carried as a base** (morph#591). A Unicode + **The digits are locale data too, carried as a base.** A Unicode decimal digit set *is* ten contiguous code points — UAX #44 assigns `Nd` with `Numeric_Value` 0 through 9 in code point order — so a single `zeroDigit` is sufficient and a ten-element table is not needed. Measured with @@ -1520,8 +1520,7 @@ Display formatting is the renderer's duty; the wire stays canonical: `src/qt/forms/tests/tst_i18n.qml`. **Entry accepts the locale's digits *and* ASCII ones; display emits only the - locale's.** That asymmetry is the rule morph#596 already set for signs, - applied to digits: an ASCII `'+'` is accepted in every locale because the + locale's.** That asymmetry is the rule the signs follow, applied to digits: an ASCII `'+'` is accepted in every locale because the locale's own spelling is on no keyboard, and an ASCII `'5'` is accepted in an `ar_EG` locale for the same reason — a user with an ASCII keyboard has to be able to type a number. It costs nothing, because the canonical output spells @@ -1544,7 +1543,7 @@ Display formatting is the renderer's duty; the wire stays canonical: every caller that does not name it is byte-identical to the five-positional-parameter version — asserted rather than assumed: a 1680-case sweep (14 locale configurations × 60 entries × both edges) run against the - pre-morph#591 header and against this one produced identical output. + positional spelling and against this one produces identical output. All the locale facts are `std::string_view`, not `char`, because a real locale's @@ -1555,9 +1554,9 @@ Display formatting is the renderer's duty; the wire stays canonical: French user normalised to `std::nullopt` and the control reported it malformed. An empty view means "this locale has no such separator". - **So is the negative sign** (morph#583). The same argument applies to the - sign, and was missing here: both edges read `NumericLocale::negativeSign`, - matched and emitted as a whole string the way the separators are. Of the 711 + **So is the negative sign.** The same argument applies to the sign: both edges + read `NumericLocale::negativeSign`, matched and emitted as a whole string the + way the separators are. Of the 711 locales `QLocale::matchingLocales` reports under Qt 6.11.2, 77 spell it as something other than a bare ASCII `'-'`: @@ -1593,7 +1592,7 @@ Display formatting is the renderer's duty; the wire stays canonical: group separator there is no locale without a negative sign, so empty cannot mean absence — and on the display edge it must not, because a sign that formatted to nothing would turn `-5` into `5`: a valid number of the wrong - sign, which is the morph#574 failure mode rather than a rejection. + sign, which is silent corruption rather than a rejection. The renderer passes the locale's own sign: `DynamicForm.qml` already binds `qtLocale: Qt.locale(displayLocale)` and forwards @@ -1601,8 +1600,8 @@ Display formatting is the renderer's duty; the wire stays canonical: `qtLocale.negativeSign` from the same object at all three call sites. The member is defaulted, so a caller that names only the separators is unchanged. - **`displayLocale` is a `QLocale` *name*; Qt resolves it, and morph does not** - (morph#629). `DynamicForm.displayLocale` (`src/qt/forms/qml/DynamicForm.qml`) + **`displayLocale` is a `QLocale` *name*; Qt resolves it, and morph does + not.** `DynamicForm.displayLocale` (`src/qt/forms/qml/DynamicForm.qml`) is a plain string, and every locale fact the two numeric edges receive comes out of the `QLocale` that `Qt.locale(displayLocale)` returns — not out of the string. That resolution is Qt's, and it is **not** an identity: a name with no @@ -1650,7 +1649,7 @@ Display formatting is the renderer's duty; the wire stays canonical: keyed on as well. **A leading positive sign is accepted on entry and never emitted on - display** (morph#596). `normalizeLocaleNumber` reads + display.** `normalizeLocaleNumber` reads `NumericLocale::positiveSign`, matched exactly as `negativeSign` is — the locale's own spelling as a whole string, plus a bare ASCII `'+'` in every locale — and **drops** what it matches: `"+5"` normalises to `"5"`, not to @@ -1682,11 +1681,11 @@ Display formatting is the renderer's duty; the wire stays canonical: somewhere to put an accepted `'+'`: nowhere, which costs nothing. The display edge has no such option: `positiveSign` is `'+'` in 657 of the 711 locales, so emitting it would turn every positive number in every form from `5` into `+5`, - a visible change to the product with no reported need behind it. morph#583 had - a forced hand — the display edge emitted a sign the entry edge rejected, so - the pair *was* broken and something had to give. Here nothing is broken: this - is new acceptance, which is why it is an enhancement and why it stops at the - one edge where acceptance is free. Rejecting text the display edge produced is + a visible change to the product with no reported need behind it. The negative + sign is a different case: there the display edge would emit a sign the entry + edge rejects, so the pair would be broken and something has to give. Nothing + is broken for the positive sign, which is why acceptance stops at the one edge + where it is free. Rejecting text the display edge produced is a defect; accepting text no display edge produces is not. **Grouping is validated, never merely stripped.** A group separator is @@ -1696,10 +1695,10 @@ Display formatting is the renderer's duty; the wire stays canonical: normalise; `"1.5"`, `"1.50"`, `"1.05"` and `"1.2.3.4"` in a de-DE locale are malformed, and so is the en-US mirror image `"1,5"`. This is not strictness for its own sake: dropping every occurrence unconditionally, as - both control edges used to, turns a de-DE user's US-style `"1.5"` into `15` — + a naive edge does, turns a de-DE user's US-style `"1.5"` into `15` — a perfectly valid number, ten times too large, that no downstream check can recognise as wrong, so the user is charged ten times with no diagnostic - anywhere (morph#574). The field's job at this edge is to report a fact to the + anywhere. The field's job at this edge is to report a fact to the layer that owns the policy, not to produce a number at any price. **The two separators must differ.** A non-empty `groupSeparator` equal to @@ -1724,14 +1723,13 @@ Display formatting is the renderer's duty; the wire stays canonical: sites that already forward `qtLocale.negativeSign`, and nothing changes at the display call site. All three call sites now also forward `qtLocale.zeroDigit`, the display one included — that is what "both edges, or - neither" costs for morph#591. + neither" costs for the digit base. **The two separators are matched the same way, for consistency rather than - for a locale** (morph#599). Both sign conversions above left the mirror's - *separator* branches spelled `ch === groupSeparator` and - `ch === decimalSeparator` — a one-code-unit comparison, a few lines from the - whole-string sign match, with nothing saying why. They are now - `text.startsWith(sep, i)` as well, advancing the index by the separator's + for a locale.** Spelling the mirror's *separator* branches as + `ch === groupSeparator` and `ch === decimalSeparator` would be a + one-code-unit comparison sitting a few lines from the whole-string sign match, + with nothing saying why. They are `text.startsWith(sep, i)` as well, advancing the index by the separator's length the way the sign branches already do. Unlike the signs, **no locale reaches this**, and the rule rather than a user @@ -2249,9 +2247,8 @@ rule list directly, has no such "unrecognised kind" case. #### "Cannot evaluate" means defer, not block "Cannot evaluate" is a **third** answer, alongside true and false, and the two -shipped clients of this sentence once read it in opposite directions — one -blocked submission on an unknown `kind`, the other deferred (morph#176). The -contract is *defer*: +shipped clients of this sentence can read it in opposite directions — block +submission on an unknown `kind`, or defer. The contract is *defer*: | Question a renderer asks | Answer when the condition cannot be evaluated | |---|---| @@ -2621,17 +2618,17 @@ instantiation for every distinct root-to-node route through the type graph. A domain model shaped like a tree has one route per node; a model shaped like a DAG — an `Address` under both a `Customer` and a `Supplier`, a `Money` everywhere — has as many as it has paths, and that count grows exponentially in -the graph's depth. morph#573 step 3 replaced the chain with a depth counter, -collapsing that to one instantiation per (type, depth) pair. Measured on a +the graph's depth. A depth counter in place of the chain collapses that to one +instantiation per (type, depth) pair. Measured on a fixture with 27 types over 8 levels, where 6,561 routes reach the deepest node (`tests/compile_checks/forms_dag_probe.cpp`, g++ 16.2.1, `-std=c++23 -fsyntax-only`, CPU seconds): 26.8 s with the ancestor chain against a 2.7 s control that has one route per node, and 3.0 s against the same control with the depth counter. `tests/compile_checks/forms_dag_budget.cmake` is the ctest guard that keeps it that way, asserting the DAG fixture costs no -more than three times its one-route control (morph#573, Part B). +more than three times its one-route control. -The depth counter is now gone too (morph#703), and the recursion carries **no** +The depth counter is gone too, and the recursion carries **no** template argument that varies down it. `recurseIntoNestedAggregateIfAny` reaches `annotateNestedAggregate` reaches `recurseIntoNestedAggregateIfAny`: every specialisation is keyed on a @@ -2665,9 +2662,9 @@ which a cyclic type always reaches: glaze inlines a nested type only when it is used exactly once in the whole schema, and a type reachable from itself never is. -Measured (morph#703), clang 22.1.8, `-std=c++23`, glaze v7.4.0 — three actions -that were each a hard `static_assert` before this change now compile, and the -generated schema is annotated correctly: +Measured with clang 22.1.8, `-std=c++23`, glaze v7.4.0 — three actions that a +depth-carrying recursion rejects with a hard `static_assert` compile here, and +the generated schema is annotated correctly: ```cpp struct TreeNode { std::string name; std::vector children; }; @@ -2698,10 +2695,9 @@ what says the change is responsible for the difference, rather than the fixture having been compilable all along. `kMaxNestDepth`, the `static_assert` and the 16-level cap are therefore gone. -The cap existed only because a depth counter cannot tell a cycle from a deep -graph; with nothing carried in the type system there is nothing to bound. It -was introduced by morph#573 step 3 where previously there had been no limit at -all, so removing it restores the older contract rather than inventing a new one. +A cap is needed only because a depth counter cannot tell a cycle from a deep +graph; with nothing carried in the type system there is nothing to bound, and +so no limit to state. #### Nesting depth in practice @@ -2730,10 +2726,10 @@ Two things about MSVC's number. It is **specific to an instantiated template**: the same chain initialised at namespace scope compiled at 120 levels without complaint, so it is not a limit on aggregate nesting as such but on the initialiser MSVC builds while instantiating. And it is **lower than the -16-level cap this change removed** — a 15- or 16-level chain would have hit -C1054 on MSVC even before morph#703, ahead of the `static_assert` that was -supposed to be the diagnostic. Nothing in the repository had ever nested more -than three levels, so nobody found out. +16-level cap a depth counter would impose** — a 15- or 16-level chain hits +C1054 on MSVC ahead of any `static_assert` meant to be the diagnostic. Nothing +in the repository nests more than three levels, so nothing in the tree reaches +either limit. The action type counts as one of the 15, so `cl` accepts a chain of **14** below it. `tests/test_nested_forms.cpp` uses 20 (four past the removed cap) @@ -2758,7 +2754,7 @@ measurement plus one link of margin, and is confirmed or refuted by the next Nothing *executes* these numbers: they are a record, not a check, so a toolchain upgrade that moves MSVC's limit down would be found by a red `Windows / cl-*` leg rather than by a named guard. A compile-check on the model -of `forms_dag_budget.cmake` is filed as morph#744. +of `forms_dag_budget.cmake` would close that, and does not exist. A "diamond" was never affected and still is not — the same type reused from two unrelated places in the schema, e.g. an `Address` nested under both a `Company` @@ -2775,8 +2771,8 @@ separate contract, stated next. The shipped `MorphForms` renderer **renders flat actions**. A nested-aggregate member — `$ref`-cyclic or not — is not drawn as a sub-form; it is flattened to a single scalar control at the parent level, and its own members reach no -control at all. This was undefined until morph#727; it is now measured, and -pinned by `src/qt/forms/tests/tst_DynamicFormNestedAggregate.qml`. +control at all. That is measured, and pinned by +`src/qt/forms/tests/tst_DynamicFormNestedAggregate.qml`. Four statements, each asserted by that suite: @@ -2808,8 +2804,8 @@ Point 4 is a description of today's behaviour, **not** an endorsement of it: a `ready` that is `true` for a payload the action must reject is the one part of this contract that is arguably wrong, and whether the renderer should draw the sub-form, decline the schema with a diagnostic, or keep flattening it is -tracked as morph#759. Nothing in this repository has a nested-aggregate member -today, so nothing depends on the answer yet. +undecided. Nothing in this repository has a nested-aggregate member today, so +nothing depends on the answer yet. So: an action with a nested-aggregate member — cyclic or otherwise — is a document morph generates completely and a form morph draws only down to the @@ -2827,11 +2823,9 @@ no nested-aggregate member has nothing here to trigger on, so its generated schema is byte-for-byte unchanged. A pre-existing action that *does* have a nested-aggregate member sees its schema gain annotations it previously lacked — the whole point of this feature — with no change to any of its flat -top-level members. The one exception is now historical: between morph#573 and -morph#703, an action nested more than 16 levels deep, or with a self- or -mutually-referential nested-aggregate member, failed to *compile*. Neither does -any longer, and no action in this repo was ever in that position — it could not -have been, since it would not have built. +top-level members. Neither a deeply nested action nor a self- or +mutually-referential nested-aggregate member fails to compile, and no action in +this repository is in that position in any case. Every nested-aggregate type in the chain must be **default-constructible**, exactly like the top-level action type (see below): the recursion builds its diff --git a/docs/spec/forms/instance_constraints.md b/docs/spec/forms/instance_constraints.md index df3a88db0..84672c4b8 100644 --- a/docs/spec/forms/instance_constraints.md +++ b/docs/spec/forms/instance_constraints.md @@ -19,8 +19,8 @@ That is worse than either key alone: a renderer is handed two numbers for one concept with no way to know which is true. Bounds fared worse still. A specification range could only be served as an `x-specHigh` no framework code read, so a value outside the range a form had just advertised passed -`validate()` and was stored with nothing anywhere recording that it was out of -range (issue #164). +`validate()` and is stored with nothing anywhere recording that it was out of +range. ## What it is diff --git a/docs/spec/forms/sections.md b/docs/spec/forms/sections.md index c77d65578..1fd092533 100644 --- a/docs/spec/forms/sections.md +++ b/docs/spec/forms/sections.md @@ -29,10 +29,10 @@ no new execution mode. `std::logic_error` on a field belonging to any other. That is right for a wizard and wrong for a screen whose blocks have no order — a settings page, a tab strip, a column of cards — where a user may edit the third block first and -never touch the second. Before this layer such a screen either hand-wired one -`BridgeHandler::execute` call site per block, re-implementing draft -accumulation and the readiness gate each time, or misused a wizard and got a -`logic_error` for editing its own form out of order (morph#513). +never touch the second. Without this layer such a screen must either hand-wire +one `BridgeHandler::execute` call site per block, re-implementing draft +accumulation and the readiness gate each time, or misuse a wizard and take a +`logic_error` for editing its own form out of order. `SectionSet` keeps everything `FlowSession` does per action — per-action draft accumulation, the readiness gate, result capture, error routing, the callback @@ -267,8 +267,9 @@ make a member field wrong to set. - `sectionGroupSchemaJson` emits `s-id`, `s-title`, each section's `action` and `title`, `prefill` only where a `Bind` is declared, and no `index` key. -- Sections fire independently in any order — the morph#513 regression: the - same edit sequence throws `std::logic_error` under `FlowSession`. +- Sections fire independently in any order — the case that separates this + layer from a wizard: the same edit sequence throws `std::logic_error` under + `FlowSession`. - A not-ready draft is not sent at all, observed through `onError` (a missing gate is visible as a spurious validation failure, not as a bad execution). - An already-fired section fires again on the next edit (the no-latch rule). diff --git a/docs/spec/forms/views.md b/docs/spec/forms/views.md index f7db18463..53cf07a39 100644 --- a/docs/spec/forms/views.md +++ b/docs/spec/forms/views.md @@ -362,10 +362,10 @@ verbatim rather than re-serialising a rounded double. The failure this prevents is silent and destructive. JavaScript numbers are IEEE-754 doubles and round to even above 2^53, so neighbouring ids collapse onto -one value: two rows became indistinguishable, and a confirmed `Delete` on one -built a body naming the other (morph#191). Nothing downstream could notice — the -server side is exact throughout, so the action decoded cleanly, validated, and -deleted precisely the wrong row. +one value: two rows become indistinguishable, and a confirmed `Delete` on one +builds a body naming the other. Nothing downstream can notice — the server side +is exact throughout, so such an action decodes cleanly, validates, and deletes +precisely the wrong row. The same guarantee for `Choice` option ids is in [choice.md](choice.md#option-ids-larger-than-253). diff --git a/docs/spec/forms/widget_hints.md b/docs/spec/forms/widget_hints.md index bbcb173fe..b322dab96 100644 --- a/docs/spec/forms/widget_hints.md +++ b/docs/spec/forms/widget_hints.md @@ -147,14 +147,14 @@ two differently-*bounded* `int` sliders share one entry — their entries are identical, and that is what `$defs` is for — while an `int` slider and a `double` slider get one each. -They have to. glaze populates a `$defs` entry only once, so while every -instantiation shared the single name `"Ranged"`, the second one was skipped and -`$ref`ed the first one's definition: a `Ranged<0.0, 1.0, 0.1>` next to a -`Ranged<0, 100>` was served as `{"type":["integer","null"], "minimum": +They have to. glaze populates a `$defs` entry only once, so if every +instantiation shared the single name `"Ranged"`, the second one would be skipped +and `$ref` the first one's definition: a `Ranged<0.0, 1.0, 0.1>` next to a +`Ranged<0, 100>` would be served as `{"type":["integer","null"], "minimum": -2147483648, …}` while its property correctly carried `"x-step": 0.1` — every -legal value of the double slider failing the type it was handed under. That was -morph#543, the same defect [choice.md](choice.md#schema-representation) -describes for `Choice`. +legal value of the double slider failing the type it was handed under. This is +the same defect [choice.md](choice.md#schema-representation) describes for +`Choice`. Because these keys are part of the emitted document, changing this composition is a wire-shape change for any client that resolves `$ref` targets by name. diff --git a/docs/spec/journal/journal.md b/docs/spec/journal/journal.md index 379e60258..050b45025 100644 --- a/docs/spec/journal/journal.md +++ b/docs/spec/journal/journal.md @@ -94,38 +94,37 @@ them live in **`journal/action_log_json.hpp`**, not in `action_log.hpp`. `action_log.hpp` is on `core/model.hpp`'s include path — `model.hpp` needs `IActionLog` for the holder's log slot and nothing else — so while the codec sat there, **every consumer that reached a model compiled -``**, whether or not it ever serialised anything. That is the -cliff morph#521 measured and morph#573 step 4 names. Measured on -`master` @ c6f6d953, clang 22.1.8, `-O2 -fsyntax-only`, one translation unit per -header, best of three: +``**, whether or not it ever serialised anything. Measured +with clang 22.1.8, `-O2 -fsyntax-only`, one translation unit per header, best +of three, with the codec in `action_log.hpp` and with it split out: -| header | before | after | +| header | codec in `action_log.hpp` | codec split out | |---|---|---| | `journal/action_log.hpp` | 2.67 CPU-s, 252,559 preprocessed lines | **0.39 CPU-s, 70,568 lines** | | `core/model.hpp` | 2.86 CPU-s, 256,954 lines | **1.30 CPU-s, 135,367 lines** | -| `core/strand.hpp` (the floor, for scale) | 1.20 CPU-s, 127,217 lines | unchanged | +| `core/strand.hpp` (the floor, for scale) | 1.20 CPU-s, 127,217 lines | unaffected | -`model.hpp` is now within 0.10 CPU-s of the async primitives it sits beside, -where it used to cost more than twice as much. +Split, `model.hpp` is within 0.10 CPU-s of the async primitives it sits beside; +unsplit it costs more than twice as much. **What this costs.** Dropping a transitive include from a header-only library is a source-breaking change for consumers, and this one is: a translation unit that -included `action_log.hpp` and called `journal::toJson` must now also include +includes `action_log.hpp` and calls `journal::toJson` must also include `action_log_json.hpp`. Inside morph exactly one header does -(`file_action_log.hpp`); four tests and one ladder-rung test did. That price was -judged worth paying here and *not* worth paying for `model.hpp`'s -`strand.hpp` include (see that file's comment), and the difference is the -measurement above: `strand.hpp` is a transitive include that costs a consumer -nothing, and this one cost 1.56 CPU-s per translation unit. - -**What it does not buy, stated plainly.** The build-level figure morph#573 step 4 -is argued over — ~90 CPU-s, ~8.7% of a kanban rung — is **not** realised by this -change alone, and this comment should not be read as claiming it. Inside morph, -every path to a `Bridge` goes through `core/registry.hpp`, which includes -`forms/forms.hpp`, which includes glaze regardless; a TU that dispatches still -pays. What this change does is make the *model-only* and *journal-only* include -paths cheap, which is a precondition for that figure rather than a down payment -on it. The remaining half — splitting `forms/forms.hpp` — is untouched. +(`file_action_log.hpp`), plus four tests and one ladder-rung test. That price is +worth paying here and *not* worth paying for `model.hpp`'s `strand.hpp` include +(see that file's comment), and the difference is the measurement above: +`strand.hpp` is a transitive include that costs a consumer nothing, and this one +costs 1.56 CPU-s per translation unit. + +**What it does not buy, stated plainly.** The build-level saving this points at +— ~90 CPU-s, ~8.7% of a kanban rung — is **not** realised by the split alone, +and this section should not be read as claiming it. Inside morph, every path to +a `Bridge` goes through `core/registry.hpp`, which includes `forms/forms.hpp`, +which includes glaze regardless; a TU that dispatches still pays. What the +split does is make the *model-only* and *journal-only* include paths cheap, +which is a precondition for that figure rather than a down payment on it. The +remaining half — splitting `forms/forms.hpp` — is untouched. `SerializationError` deliberately stays in `action_log.hpp`: a caller catching it needs only ``, and making that catch drag in glaze would put the @@ -419,13 +418,12 @@ PayloadMigrationRegistry& defaultPayloadMigrations(); The backing store is `unordered_map, Migration, model::detail::PairKeyHash, -model::detail::PairKeyEqual>`, and **both functors matter** (morph#699). This -map named `PairKeyHash` alone for as long as it existed; +model::detail::PairKeyEqual>`, and **both functors matter**. `std::unordered_map` enables heterogeneous lookup only when the hash *and* the -equality are transparent, so `find` silently built a `pair` to -probe with while looking like it did not. With `PairKeyEqual` in place `find` -takes a `detail::PairKeyView` directly. `add` still builds a key, because it -inserts one. +equality are transparent, so naming `PairKeyHash` alone leaves `find` silently +building a `pair` to probe with while looking like it does not. +With `PairKeyEqual` in place `find` takes a `detail::PairKeyView` directly. +`add` still builds a key, because it inserts one. **Measured** with `morph_bench_alloc`'s migration census (clang 22.1.8 / libstdc++ 16.2.1, 200 lookups, 50 warm-up excluded): for a pair whose ids both @@ -461,7 +459,7 @@ the journal that needs it can be read again. | **Warn and reconstruct anyway** | The failure mode being fixed is *confident wrongness*. Handing back a suspect holder plus a log line reproduces it with extra steps. | | **Full per-action version numbers with a registered decoder per version** | Strictly more expressive, and strictly more machinery: an author must remember to bump the version, which is the same discipline the additive-only rule already asks for and does not get. A fingerprint is derived, so it cannot be forgotten. Migrations recover the expressiveness where it is actually needed. | -### Relationship to the wire path (#207) +### Relationship to the wire path The identical leniency exists on the live wire path, and is **not** addressed here. It is a different decision with a different answer: `docs/spec/core/wire.md` @@ -469,7 +467,7 @@ publishes an "Action-evolution policy" whose first bullet is additive-only *within* a deployment window, and the handshake (`kProtocolVersion`) is its designed defence. The journal's scope is retention, not deployment — a journal can outlive every peer that ever wrote to it — which is why the two paths get -different mechanisms. See issue #207. +different mechanisms. ## Data-at-rest contract @@ -523,9 +521,8 @@ the append-only rule so much as a boundary of it: `FileActionLog::`[`rotate()`](#rotation-and-retention), which seals the active file and reopens an empty one, and `morph::core::repairTornTail()`, which discards a truncated trailing record and runs only from this class's -constructor. It was private to `FileActionLog` until morph#530 lifted it into -`core/file_io_ops.hpp` so the logic has one home; `FileOfflineQueue` -deliberately does not call it (see `docs/spec/offline/offline.md`). An `IActionLog` implementation over another sink +constructor. It lives in `core/file_io_ops.hpp` rather than here so the logic +has one home; `FileOfflineQueue` deliberately does not call it (see `docs/spec/offline/offline.md`). An `IActionLog` implementation over another sink owes neither. | Method | Signature | Purpose | @@ -561,7 +558,7 @@ defaulting to the real syscalls, letting a test force the failure branches that otherwise need a real OS-level I/O error to reach. A normal caller never passes one. Throws `std::runtime_error` if the file cannot be opened, or if the containing directory's fsync fails for a reason that is a genuine I/O failure -(morph#532) — a directory fsync the platform or mount simply cannot perform +— a directory fsync the platform or mount simply cannot perform warns and continues instead; see [Directory durability](#directory-durability). Closes the file in the destructor. Copy and move are deleted. @@ -613,7 +610,7 @@ surfaced to the caller. write; `flush()` throws if either `fflush` or the `fsync`/`_commit` fails; `rotate()` throws if its pre-rotation flush fails, before anything is closed or renamed. `rotate()` also throws *after* a fully successful rename and reopen if -either affected directory's fsync fails for a genuine I/O reason (morph#532): +either affected directory's fsync fails for a genuine I/O reason: the entries are all present and the rotation did happen, but the directory entries naming them are not yet durable, and this class's contract is that an unreported I/O failure is the one thing it never does. An unsupported directory @@ -630,9 +627,9 @@ anywhere. Creating or renaming a file is a **directory** mutation. An `fsync` on the file itself makes its *contents* durable and says nothing about the directory entry that names it, so a crash can leave a fully-fsynced file that no longer appears -in its directory. morph#532 closed that gap: `FileActionLog` fsyncs the -containing directory after its constructor creates the file, and after -`rotate()`'s rename and reopen. +in its directory. `FileActionLog` therefore fsyncs the containing directory +after its constructor creates the file, and after `rotate()`'s rename and +reopen. It is a **ceiling, not a guarantee**, and this spec says so rather than leaving it to be discovered: @@ -1277,9 +1274,9 @@ and `RemoteServer::setLogProvider(LogProvider)`, declared in `remote.hpp`. See | `FileActionLog::seq` is process-local | **Fresh per process, not resumed from disk** | `seq` is a monotonic order key within one process instance, not a cross-restart durable identifier. On-disk order is append order; `entries()` returns in that order regardless of `seq` gaps. | | `FileActionLog` uses C stdio + `fsync` | **`fopen`/`fwrite`/`fflush`/`fsync`** | `fwrite` is buffered; `flush()` calls `fflush` then `fsync` (or `_commit` on Windows) for real durability. POSIX `write`/`fsync` would bypass stdio buffering entirely; C stdio gives buffering by default with explicit flush control. | | `FileActionLog::entries` tolerates a torn trailing line | **Skip + warn on the last line only; re-throw mid-file** | A crash between `append`'s `fwrite` and the next flush can truncate the final line. Skipping it keeps the log readable after a crash; re-throwing on interior damage refuses to silently hide real corruption. | -| An **unreadable** journal is not an empty or torn one | **`repairTornTail()` leaves the file untouched; `entries()` throws** | Both scan with an `ifstream`. When that open fails — or a read errors mid-scan — nothing was read, so `repairTornTail()`'s safety argument ("whatever follows the final newline is by construction an incomplete record") does not hold, and truncating to the scan's `intactEnd` discarded the whole journal while logging it as a successful repair. `entries()` distinguishes *absent* (legitimately empty, which the constructor's dedup rebuild depends on) from *present but unreadable*: returning `{}` for the second silently emptied the `idempotencyKey` dedup set `OutboxRelay` relies on. See morph#493. | +| An **unreadable** journal is not an empty or torn one | **`repairTornTail()` leaves the file untouched; `entries()` throws** | Both scan with an `ifstream`. When that open fails — or a read errors mid-scan — nothing was read, so `repairTornTail()`'s safety argument ("whatever follows the final newline is by construction an incomplete record") does not hold, and truncating to the scan's `intactEnd` would discard the whole journal while logging it as a successful repair. `entries()` distinguishes *absent* (legitimately empty, which the constructor's dedup rebuild depends on) from *present but unreadable*: returning `{}` for the second would silently empty the `idempotencyKey` dedup set `OutboxRelay` relies on. | | `InMemoryActionLog`/`FileActionLog` dedup on `idempotencyKey` | **Non-empty key only; `SessionLog` excluded** | Makes both safe default choices for `OutboxRelay::sink` without changing behavior for callers that never set the key (empty key never dedups). `SessionLog` is excluded because its contract is full fidelity — nothing coalesced or dropped. | -| Payload evolution is **detected**, not prevented | **Fingerprint stamped per entry; `replay()` refuses a mismatch** | The additive-only [data-at-rest contract](#data-at-rest-contract) was already published and already unenforced. Strict decode would reject the additive change the contract permits; a lint sees one commit while a journal outlives the deployment that wrote it. A derived fingerprint cannot be forgotten the way a hand-maintained version number can. | +| Payload evolution is **detected**, not prevented | **Fingerprint stamped per entry; `replay()` refuses a mismatch** | The additive-only [data-at-rest contract](#data-at-rest-contract) is a published rule that nothing else enforces. Strict decode would reject the additive change the contract permits; a lint sees one commit while a journal outlives the deployment that wrote it. A derived fingerprint cannot be forgotten the way a hand-maintained version number can. | | A mismatch throws rather than warning | **`SchemaMismatchError` out of `replay()`** | The defect is confident wrongness. A suspect holder plus a log line reproduces it with extra steps, and puts the burden of noticing on the code path that demonstrably did not notice. | | The fingerprint is order-insensitive and compiler-independent | **Key-sorted shape rendering from `std::` traits and reflected key strings** | Reordering members changes nothing about which JSON bytes decode where, so an order-sensitive digest would break replay for a cosmetic edit. A `glz::name_v`-derived tag would be compiler-spelled, making a journal readable only by the compiler that wrote it. | | A custom-codec type is distinguished by a name it declares, not one the compiler spells | **Opt-in `PayloadShapeTag` specialisation, defaulting to the opaque `x`** | The portability requirement rules out the only *derived* per-type name available, so the name has to be author-written. Opt-in keeps that cost on the handful of types that need it, at the price of a new type silently starting out undeclared — stated as a boundary rather than assumed away. | @@ -1444,9 +1441,9 @@ Honest boundaries of the current design: renamed and an added field, run in order as the `journal_skew_old_build_writes` / `journal_skew_new_build_replays` ctest pair. The wire path cannot be tested the same way, because nothing mechanically enforces the action-evolution - policy there yet — a per-action fingerprint exchanged at `hello` is issue - #207's unimplemented proposal, and until it exists a client/server skew test - has nothing to assert on. + policy there — a per-action fingerprint exchanged at `hello` would be the + mechanism, and until one exists a client/server skew test has nothing to + assert on. - **`replay()` refuses an additive change, not only a breaking one.** The gate is fingerprint equality, so an entry written before a field was *added* throws exactly as a renamed one does, even though the diff --git a/docs/spec/offline/offline.md b/docs/spec/offline/offline.md index 2da3f95c6..13d9c63da 100644 --- a/docs/spec/offline/offline.md +++ b/docs/spec/offline/offline.md @@ -191,13 +191,11 @@ it, and a host must pick one: ### `IReplayLedger`: the promoted replay-consumer half -The two paragraphs above define the *contract* — dedup on a shared key — but -for five rungs and seven call sites, morph supplied no *mechanism*: each host -hand-wrote its own op-id-keyed table answering "has this already been -applied?" ([morph#226](https://github.com/LASTRADA-Software/morph/issues/226), -`examples/IMPLEMENTATION.md`'s promotion rule fired three rungs past its own -trigger point). `morph::offline::IReplayLedger` -(`include/morph/offline/replay_ledger.hpp`) is the promoted answer: +The two paragraphs above define the *contract* — dedup on a shared key. The +mechanism is `morph::offline::IReplayLedger` +(`include/morph/offline/replay_ledger.hpp`), and it lives in the framework +because five rungs across seven call sites otherwise each hand-write the same +op-id-keyed table answering "has this already been applied?": ```cpp struct IReplayLedger { @@ -219,8 +217,8 @@ SQL dependency to open a connection with. Every existing occurrence stores its ledger row in the *same database and the same transaction* as the write it guards, so the check-then-set commits atomically with the operation's effect; a morph-owned store opening its own connection would break exactly that -atomicity (morph#458 was this defect, shipped in two rungs, before this -interface existed). So the table, the connection, and the transaction stay +atomicity — a defect that has shipped in two rungs when the ledger was written +by hand. So the table, the connection, and the transaction stay app-side, per rung — a concrete `IReplayLedger` is constructed over the model's *already-open* mapper/transaction (see `BookmarksReplayLedger` in `examples/bookmarks/src/models/bookmark_model.cpp` for the reference shape — @@ -257,15 +255,14 @@ constructed in exactly one file in this tree `IReplayLedger::lookup()` call site (`examples/bookmarks/src/models/bookmark_model.cpp`, once per `ImportBookmarks`) runs against `BookmarksReplayLedger`, whose `doLookup` is a -SQL round-trip. Measured on `d03c66f3` (clang 22 `-O2`, counting -`operator new`, 2e6 iterations): 2.00 allocations per lookup with both key -halves past libstdc++'s 15-character SSO buffer, 1.00 with one past it, 0.00 -with both inside — costing 3.4 ns of a 31.6 ns uncontended lookup, and 55 ns -of a 740 ns lookup with eight threads on the mutex. This is -[morph#728](https://github.com/LASTRADA-Software/morph/issues/728), parked on -the same grounds and with the same kind of number as morph#709. It becomes -worth doing the moment a per-request caller of this class exists; the header's -own `doLookup` comment carries the full table and the shape of the fix. +SQL round-trip. Measured with clang 22 `-O2`, counting `operator new`, 2e6 +iterations: 2.00 allocations per lookup with both key halves past libstdc++'s +15-character SSO buffer, 1.00 with one past it, 0.00 with both inside — costing +3.4 ns of a 31.6 ns uncontended lookup, and 55 ns of a 740 ns lookup with eight +threads on the mutex. It is parked on that census rather than on the size of +the number, and becomes worth doing the moment a per-request caller of this +class exists; the header's own `doLookup` comment carries the full table and +the shape of the fix. ### `IOfflineQueue` @@ -336,10 +333,10 @@ promises monotonicity nor rules out an id minted from a GUID, a content hash, or a sharded sequence, and a store that reuses the id of a removed row does not order correctly either. An implementation over such a store carries its own insertion sequence and orders on that. This is written down because an -ORM-backed queue is the first implementation for which the choice was a -*choice* rather than the only option available (morph#549); the ordering is -asserted by `tests/offline_queue_conformance.hpp`, so getting it wrong fails -there rather than in production. +ORM-backed queue is the first implementation for which the ordering is a +*choice* rather than the only option available; the ordering is asserted by +`tests/offline_queue_conformance.hpp`, so getting it wrong fails there rather +than in production. Both `enqueue` overloads, `drain`, `size`, and `maxDepth` are `[[nodiscard]]` on the interface and on every shipped override (`InMemoryOfflineQueue`, @@ -446,7 +443,7 @@ completed and acknowledged item. Mutations also raise rather than swallow I/O failures: a short write or a failed `fflush`/`fsync` throws, since every mutation is documented as a committed transaction by the time the call returns. -A **failed write is rolled back before it throws** (morph#530). The file is +A **failed write is rolled back before it throws**. The file is opened `"a"`, so a partial line's bytes sit exactly where the next `writeLine` would resume, with no separating newline — the two merge into a single line that `load()` tolerates only while it remains the *trailing* one, and stops @@ -482,21 +479,20 @@ later writes keeps the torn record trailing, which is exactly the shape the next open's `load()` skips and `compact()` rewrites away. See [file_io_ops.md](../core/file_io_ops.md), "Rolling back a short write". -**No constructor-time `repairTornTail`.** An earlier revision of morph#530 ran -it before `load()`, to heal "an interior merge from a doubled-up short write". -It cannot do that — it only trims bytes after the final newline, and says so -itself — so it never fixed the case it was added for. It did cost two things: -it is the constructor's only file mutation that can run *before* `load()` -throws, which breaks morph#494's guarantee that a failed construction leaves the -file byte-identical, and it discards a complete final record whose only missing -byte is the trailing newline, wiping the file outright when that is the only -line. What prevents the doubled-up short write is the rollback above; `load()` + -`compact()` heal an ordinary torn tail as they always have. `FileActionLog` -keeps its own long-standing call — pre-existing behaviour there, not something -morph#530 introduced. +**No constructor-time `repairTornTail`.** Running it before `load()` looks like +a way to heal an interior merge from a doubled-up short write. It cannot do +that — it only trims bytes after the final newline, and says so itself — and it +would cost two things: it would be the constructor's only file mutation able to +run *before* `load()` throws, breaking the guarantee that a failed construction +leaves the file byte-identical, and it discards a complete final record whose +only missing byte is the trailing newline, wiping the file outright when that is +the only line. What prevents the doubled-up short write is the rollback above; +`load()` + `compact()` heal an ordinary torn tail. `FileActionLog` does call it, +from its own constructor, where its trimming rule is part of that class's +contract. `compact()` additionally fsyncs the **containing directory** after its -`rename()` (morph#532): the fsync on the temporary file makes the compacted +`rename()`: the fsync on the temporary file makes the compacted *data* durable and says nothing about the directory entry that now names it `_path`. A directory fsync the platform or mount cannot perform is logged at `warn` and construction continues; only a genuine I/O failure throws. See @@ -505,17 +501,17 @@ exists and which cases fall on each side. Mutations are also ordered **durable-first**: `markDone()` appends the tombstone before erasing from `_items`, and `setAttempts()` writes before updating memory. -The reverse order meant a throwing append left the item gone from memory with no -tombstone on disk, so this process never replayed it and a restart resurrected -and re-applied it; durable-first fails the other way, replaying once too often at -worst, which `idempotencyKey` exists to absorb (morph#494). +The reverse order would let a throwing append leave the item gone from memory +with no tombstone on disk, so this process never replays it and a restart +resurrects and re-applies it; durable-first fails the other way, replaying once +too often at worst, which `idempotencyKey` exists to absorb. An **unreadable** queue file is not an empty queue. `load()` reads with its own `ifstream`, and the constructor calls `compact()` immediately after — which rewrites the file from whatever `load()` produced. A failed open or a mid-file -read error therefore committed an empty set over the real backlog, with the -constructor returning normally and the queue reporting no pending work. Both now -throw, so `compact()` cannot run on a load that did not succeed (morph#494). A +read error would therefore commit an empty set over the real backlog, with the +constructor returning normally and the queue reporting no pending work. Both +throw instead, so `compact()` cannot run on a load that did not succeed. A keyed `enqueue`'s dedup is a linear scan over pending items — fine at modest queue depths; `SqliteOfflineQueue` is the index-backed alternative for high-volume keyed enqueues. Not safe for multiple processes to open the same @@ -567,12 +563,12 @@ and `markDone()` loses nothing; every write is its own committed statement under `PRAGMA journal_mode=WAL`. All operations serialise on an internal mutex, so the queue is safe to share between the write and drain/replay paths. -**Durability settings, set once at construction** (morph#532), in this order — -the order is load-bearing: +**Durability settings, set once at construction**, in this order — the order is +load-bearing: | Order | Pragma | Value | Why | |---|---|---|---| -| 1 | `busy_timeout` | `busyTimeout` ctor param, default 5000 ms | Must come **first**: converting a database to WAL needs an exclusive lock, so `journal_mode=WAL` is itself a `SQLITE_BUSY` candidate. Set last, as an earlier revision did, the multi-opener case it was added for failed exactly as before (measured: 12 ms to throw "database is locked" with no timeout, a full 1001 ms wait with a 1000 ms timeout set first). | +| 1 | `busy_timeout` | `busyTimeout` ctor param, default 5000 ms | Must come **first**: converting a database to WAL needs an exclusive lock, so `journal_mode=WAL` is itself a `SQLITE_BUSY` candidate. Set last, the multi-opener case it exists for fails exactly as if it were unset (measured: 12 ms to throw "database is locked" with no timeout, against a full 1001 ms wait with a 1000 ms timeout set first). | | 2 | `synchronous` | `Synchronous` ctor param, default `normal` | Before `journal_mode`, and unconditional: SQLite's rollback-journal default is already `FULL`, and it is WAL that lowers it to `NORMAL`. Setting it first means the level holds whether or not WAL takes. | | 3 | `journal_mode` | `WAL` | Read back and **warned about**, not enforced. | @@ -624,14 +620,14 @@ at all wherever that fsync genuinely fails. `sqlite3_db_filename` reports an empty name for exactly those spellings, so an empty result means "no backing file, nothing to sync" and the step is skipped. -**A NUL byte inside a payload or idempotency key survives a round trip** -(morph#531). `payload` and `idempotencyKey` are opaque strings whose -serialisation the caller owns, so an embedded NUL is legitimate. Both halves -previously truncated at the first one: `sqlite3_bind_text` was called with -length `-1`, telling SQLite to measure to the first NUL, and the read side -constructed a `std::string` from the bare `const char*`. Writes now pass an -explicit `value.size()` (throwing if it exceeds `INT_MAX`, which the `int` -parameter cannot represent) and reads use `sqlite3_column_bytes()` for the +**A NUL byte inside a payload or idempotency key survives a round trip.** +`payload` and `idempotencyKey` are opaque strings whose serialisation the caller +owns, so an embedded NUL is legitimate. Both halves truncate at the first one +unless this is handled: `sqlite3_bind_text` with length `-1` tells SQLite to +measure to the first NUL, and a `std::string` constructed from the bare +`const char*` stops there too. Writes therefore pass an explicit `value.size()` +(throwing if it exceeds `INT_MAX`, which the `int` parameter cannot represent) +and reads use `sqlite3_column_bytes()` for the stored length. ## Ownership: who enqueues @@ -685,7 +681,7 @@ The example above puts domain-adjacent code in a free function at the dispatch site. `examples/IMPLEMENTATION.md` rule 1 would otherwise forbid exactly that placement — "nothing domain-shaped may live in presenters, QML, `main()`, or free functions." The placement is deliberate, and this section is its recorded -disposition (morph#197), so a reader who finds +disposition, so a reader who finds `if (!monitor.isOnline()) queue.enqueue(...)` outside a model knows it is a sanctioned exception rather than an oversight. @@ -798,7 +794,7 @@ and calls a caller-supplied `ReplayFunction` for each item. make that reachable rather than theoretical: `ReconnectCoordinator::onOnline()` holds its mutex for the whole retry loop, so a flap back offline cannot preempt an in-progress replay; and nothing in the framework wires a - `NetworkMonitor` transition to `SyncWorker::stop()`. See issue #343. + `NetworkMonitor` transition to `SyncWorker::stop()`. A caller that cannot tell the two apart must report `Rejected`. "I don't know" is not `Undelivered`: reading it that way retries a genuinely poisonous diff --git a/docs/spec/security.md b/docs/spec/security.md index 93abe85a1..dffda56ac 100644 --- a/docs/spec/security.md +++ b/docs/spec/security.md @@ -626,15 +626,15 @@ transport above is not a matter of degree: `std::system_category().message()`, which returns an owned `std::string` and carries the library's ordinary "shall not introduce a data race" guarantee, rather than `std::strerror`, which is permitted to hand every caller a - pointer to one shared static buffer (morph#625). Stated precisely, because - the distinction matters: what was repaired is the data race the - specification of `std::strerror` permits, inferred from the code. No - interleaved or corrupted message was ever observed, and on the glibc/Linux - configuration this project tests, the two spellings render an `errno` to - identical bytes. The property gained is that the guarantee now holds by - specification rather than by the implementation happening to be safe. - `TcpSocket::connect`'s `::gai_strerror` is deliberately untouched, for two - separate reasons that morph#640 asked to be kept apart. The first is that no + pointer to one shared static buffer. Stated precisely, because the + distinction matters: what this avoids is the data race the specification of + `std::strerror` permits, inferred from that specification rather than + observed. No interleaved or corrupted message has been seen, and on the + glibc/Linux configuration this project tests, the two spellings render an + `errno` to identical bytes. The property gained is that the guarantee holds + by specification rather than by the implementation happening to be safe. + `TcpSocket::connect`'s `::gai_strerror` is deliberately left as it is, for + two reasons that are worth keeping apart. The first is that no substitution exists: it renders `EAI_*` resolver codes, which are not `errno` values, so `std::system_category().message()` would describe them confidently and wrongly. The second is that it does not have diff --git a/docs/spec/testing_strategy.md b/docs/spec/testing_strategy.md index 836f1b9f0..0dfe90616 100644 --- a/docs/spec/testing_strategy.md +++ b/docs/spec/testing_strategy.md @@ -189,7 +189,7 @@ their pass/fail signal never depends on how (or whether) a host application has wired up observability. Both `morph_soak` and `morph_bench` are sanitizer-instrumented when -`AF_SANITIZER` is set (morph#542). They are opt-in, so no default sanitizer leg +`AF_SANITIZER` is set. They are opt-in, so no default sanitizer leg pays for them; the reason to instrument them rather than exempt them is that churn over thousands of cycles is exactly the shape of test whose finding is a leak or a race and not a failed assertion. Under a sanitizer preset the @@ -209,10 +209,10 @@ overhead from business logic): executes/second (`MORPH_BENCH_WINDOW_MS`, default 200 ms). - **Both phases run `MORPH_BENCH_TRIALS` times (default 5)**, and the run reports the best, median and worst trial of every figure rather than one - number — morph#687, below. + number — see below. - Writes `bench_dispatch_latency.json` into the build directory. The - `p50_ms`/`p95_ms`/`p99_ms`/`throughput` keys of the old schema are still - there and now carry the *best* trial; `trials`, + `p50_ms`/`p95_ms`/`p99_ms`/`throughput` keys carry the *best* trial; + `trials`, `latency_samples_per_trial`, `throughput_window_ms`, `p99_ms_median_trial`, `p99_ms_worst_trial`, each throughput point's `executes_per_sec_worst_trial`, and a `trial_detail` array with every @@ -225,10 +225,10 @@ overhead from business logic): environment-variable-overridable so CI hardware differences don't need a code change, and both read the **best** trial — see below. -**The serial phase used to report the test harness's polling step (morph#687).** -It waited on each reply with `morph::testing::WaitReply`, whose `await()` calls -`waitUntil`, which sleeps 5 ms between predicate checks. Measured on -`e9dad027`, same binary, 20 processes per configuration, Release: +**The serial phase must not wait on a polling step, or it reports the poll.** +Waiting on each reply with `morph::testing::WaitReply` does exactly that: its +`await()` calls `waitUntil`, which sleeps 5 ms between predicate checks. +Measured that way, same binary, 20 processes per configuration, Release: | | idle | 16-way oversubscribed | | --- | --- | --- | @@ -236,20 +236,19 @@ It waited on each reply with `morph::testing::WaitReply`, whose `await()` calls | c=1 executes/sec | 171467 / 175928 / 178855 | 311.9 / 1749.9 / 18627.1 | A **302x** swing in the headline figure, selected by machine load, and neither -mode was the dispatch latency: the same idle processes reported ~176k -executes/sec at concurrency 1, i.e. a round trip of about 5.7 µs. An idle -machine reported one whole sleep step per call; a busy one reported the case -where the reply beat the caller's first predicate check. 311.9 executes/sec is -also *below* the 500/sec floor the file has always enforced, so the gate was -already firing on machine load rather than on morph. The benchmark now uses a -local condition-variable reply sink, and a blocking drain at the end of each -throughput window for the same reason — the old polling drain sat inside the -window's own elapsed time. +mode is the dispatch latency: the same idle processes report ~176k executes/sec +at concurrency 1, i.e. a round trip of about 5.7 µs. An idle machine reports one +whole sleep step per call; a busy one reports the case where the reply beats the +caller's first predicate check. 311.9 executes/sec is also *below* the 500/sec +floor this file enforces, so the gate would fire on machine load rather than on +morph. The benchmark therefore uses a local condition-variable reply sink, and a +blocking drain at the end of each throughput window for the same reason — a +polling drain sits inside the window's own elapsed time. **It reports a distribution because a wall-clock figure has no regime to pin.** -`morph_bench_alloc` answers morph#687 by pinning its race, and an allocation -count then comes out exact. Contention is not a mode, it is a tax, so this -benchmark takes morph#687's other option and reports the spread over trials. +`morph_bench_alloc` can pin its race, and an allocation count then comes out +exact. Contention is not a mode, it is a tax, so this benchmark reports the +spread over trials instead. The gates read the best trial: contention can only make latency worse and throughput lower, so the best of N is the least contaminated estimate of what the code costs, while a real regression moves every trial including the best. @@ -270,12 +269,12 @@ timidity**: Debug-under-load still spans 28x on throughput and 57x on p99 for the same binary, so no pair of constants separates a regression from a contended runner. 50 ms is ~27x the worst best-trial p99 measured and 500/sec ~3.1x below the worst best-trial throughput — a dispatch path made three times -slower passes both. That limit is morph#707; setting the floor from a 12-core -box was tried and turned 3 of 20 Debug-under-load processes red. +slower passes both. Setting the floor from a 12-core box instead turns 3 of 20 +Debug-under-load processes red, so that is not the way out of it either. -**The property those ceilings were feared to be leaving ungated is gated -elsewhere, tightly.** morph#707 asks that an allocation gate be weighed before -any wall-clock ceiling is tightened. It already exists: `bench.alloc_budget` +**The property those loose ceilings look like they leave ungated is gated +elsewhere, tightly.** An allocation gate is what to weigh before tightening any +wall-clock ceiling, and it exists: `bench.alloc_budget` (below) is set by rule at one allocation above the figure the benchmark measures — not a tolerance band — and that figure came out identical on every one of 20 processes across three toolchains, idle and loaded alike: a @@ -283,10 +282,10 @@ one-allocation margin, on a quantity machine load cannot move. **The two numbers are deliberately not restated here.** Both live in `tests/bench/CMakeLists.txt` — the ceiling as `MORPH_ALLOC_BUDGET_PER_CALL`, the measured figure in the comment that derives it — and they move together -every time the dispatch path gets cheaper. When this paragraph carried copies -of them they went stale twice in three days (morph#743, morph#758), while the -argument they were quoted for survived both unchanged; so the argument is what -this paragraph keeps, and the file above is where the current values are. +every time the dispatch path gets cheaper. Copies of them in this paragraph went +stale twice in three days, while the argument they were quoted for survived both +unchanged; so the argument is what this paragraph keeps, and the file above is +where the current values are. So "dispatch does not get more expensive" is already gated to a resolution no wall-clock constant on any host can approach. What the two ceilings gate is the residue: a regression that costs time without costing allocations — a spin, a @@ -297,15 +296,14 @@ they are for. - **A cross-process distribution** (`bench_dispatch_latency.jsonl`, beside the per-process `.json`, overridable with `MORPH_BENCH_LEDGER`, `off` to - disable). This is morph#687's remaining half: `MORPH_BENCH_TRIALS` gives a - distribution over trials *within* a process, which mitigates the spread but - does not record it — one process still prints one triple and cannot say + disable). `MORPH_BENCH_TRIALS` gives a distribution over trials *within* a + process, which mitigates the spread but does not record it — one process still prints one triple and cannot say where it sits among others. Each run appends one line (`pid`, the headline percentiles, concurrency-1 throughput, `load_1m`, `inject_delay_us`) and prints its own rank among every line already there, so N unorchestrated runs produce the distribution a candidate ceiling would be read off. The load - average is on the row and not only on stdout, because morph#710's sweeps put - the same binary 28x apart on throughput between an idle box and a loaded one: + average is on the row and not only on stdout, because load sweeps put the same + binary 28x apart on throughput between an idle box and a loaded one: a figure without its load is not comparable with another figure. Rows are appended `O_APPEND` in one write and a row that cannot be parsed is skipped, because a diagnostic ledger must never redden a benchmark. @@ -331,15 +329,14 @@ they are for. This is the first time either `CHECK` has been shown firing on anything. The baseline round trip is ~5.9 µs, so the throughput floor — the tighter of the two — first speaks at roughly a **340×** regression and the p99 ceiling at -roughly **8500×**, confirming morph#707's ~800× estimate by measurement and -understating it for the p99 half. The ratio, not the time, is the portable -part: reproduce the table on any host with `MORPH_BENCH_INJECT_DELAY_US`. +roughly **8500×**. The ratio, not the time, is the portable part: reproduce the +table on any host with `MORPH_BENCH_INJECT_DELAY_US`. -What is still **not** done, and is why morph#707 stays open: nobody has +What is **not** established, and is why the ceilings stay loose: nobody has characterised the CI runner. Both sets of figures above come from a -workstation, which morph#707 identifies as exactly the misleading -configuration, so the ceilings are not tightened here. Guessing a second time -from the same box would be the first mistake with a different number. +workstation, which is exactly the misleading configuration to set a CI ceiling +from. Guessing a second time from the same box would be the first mistake with a +different number. The echo model/action (`BenchEchoModel`/`BenchEchoAction`) are declared at file scope, not inside the file's anonymous namespace with its other local @@ -362,22 +359,22 @@ ids inside it — the cost is entirely id-length-dependent, so a census over one side alone either measures zero or overstates the saving, and morph's real ids straddle the line (`"CreateSwimlane"` is 14 characters, one under): -| census | before | after | pure lookup? | +| census | opaque key | transparent key | pure lookup? | | --- | --- | --- | --- | -| `ActionDispatcher::coalesce` + `requiredFieldsFor` | 2.00 / 0.00 | 0.00 / 0.00 | yes (morph#572 Part C) | -| `PayloadMigrationRegistry::find` | 2.00 / 0.00 | 0.00 / 0.00 | yes (morph#699) | -| `BridgeHandler::executeJson` | 24.07 / 21.06 | 21.07 / 21.06 | no — a whole round trip (morph#699) | -| `ModelRegistryFactory::create` | 2.00 / 1.00 | unchanged | no — constructs a holder (morph#709, parked) | +| `ActionDispatcher::coalesce` + `requiredFieldsFor` | 2.00 / 0.00 | 0.00 / 0.00 | yes | +| `PayloadMigrationRegistry::find` | 2.00 / 0.00 | 0.00 / 0.00 | yes | +| `BridgeHandler::executeJson` | 24.07 / 21.06 | 21.07 / 21.06 | no — a whole round trip | +| `ModelRegistryFactory::create` | 2.00 / 1.00 | 2.00 / 1.00 | no — constructs a holder; parked | The first two decode nothing and execute nothing, so what they allocate is exactly what looking a registry key up costs. The last two do more than look up, so their figure is a floor plus the key and what is comparable between runs is the *difference* between the long-id and short-id columns. -It exists because morph#572 is scoped by a number that three later pull -requests invalidated, and re-deriving such a number from a prose description of -how it was once taken is how a fix ends up built against a figure nobody -re-checked. +It exists because a scoping number goes stale the moment anything on the path +changes, and re-deriving one from a prose description of how it was once taken +is how a fix ends up built against a figure nobody re-checked. The census +re-takes it on demand instead. **It is both an instrument and a control, and which one it is at any moment depends on the flags.** Run bare, it asserts nothing and the number it prints @@ -396,9 +393,9 @@ toolchains that actually build this target in CI (Linux `clang-debug` and to 0 to disable the gate. `tests/bench/CMakeLists.txt` carries the measurements the default was chosen from and the headroom argument. -The `--lookup-budget` half takes no headroom at all: after morph#572's Part C -and morph#699 a *pure* registry lookup allocates **nothing**, for ids of any -length, and that is a property which either holds or has regressed. The ctest +The `--lookup-budget` half takes no headroom at all: with transparent keys a +*pure* registry lookup allocates **nothing**, for ids of any length, and that is +a property which either holds or has regressed. The ctest case passes `--lookup-budget=0`, and it covers `ActionDispatcher` and `PayloadMigrationRegistry`. @@ -408,11 +405,10 @@ gated is the gap between an `executeJson` over long ids and one over short ones. **0.5 rather than 0, and the 0.5 is measured**: the figure reads 0.01 because of a deterministic two-allocation one-off across a census's 200 calls, shown to follow census *order* rather than id length by swapping the two -censuses, and identical in all of 10 processes. Reverting either half of -morph#699's change to that path takes the gap to 3.00 (the `Key`'s two -`std::string`s, plus the one `executeJson` built from -`ModelTraits::typeId()`), so 0.5 separates the residue from the thing -guarded with wide margin on both sides. Like the lookup half it needs no +censuses, and identical in all of 10 processes. Making either half of that key +opaque again takes the gap to 3.00 (the `Key`'s two `std::string`s, plus the one +`executeJson` builds from `ModelTraits::typeId()`), so 0.5 separates the +residue from the thing guarded with wide margin on both sides. Like the lookup half it needs no per-toolchain default: it is a difference between two runs of one build. All three ceilings were checked against a reverted fix. Removing @@ -421,31 +417,29 @@ turns the case red; restoring `ActionExecuteRegistry`'s `Key{...}` temporary takes `--id-length-budget` to 2.01; restoring `executeJson`'s `std::string{typeId()}` alone takes it to 1.01. -**Both halves were checked against a reverted fix rather than only against -themselves** — see morph#572's pull request for the red runs. A budget that has -never been seen to fail is the control-that-measures-nothing this document's -own charter warns about. +**Both halves are checked against a reverted fix rather than only against +themselves**: each was run with the transparent keys taken back out, and each +went red. A budget that has never been seen to fail is the +control-that-measures-nothing this document's own charter warns about. **The dispatch census pins a race, and without that pin it is not comparable between runs.** A dispatch and the handlers attached to it race: win, and each handler joins a vector the settle drains; lose, and each takes -`CompletionState`'s attach-after-ready path, which costs differently. Before -the benchmark gated its worker thread, that made the headline figure bimodal — -measured, interleaved, 20 processes per configuration: **~16.95 on an idle -machine, ~13.06 with the machine 16-way oversubscribed**, same binary. That is -morph#687's instability with a cause attached. The benchmark now holds the -worker across `execute()` and both attaches, so it always measures the -attach-before-settle regime: the more expensive of the two, and the one a real +`CompletionState`'s attach-after-ready path, which costs differently. Ungated, +that makes the headline figure bimodal — measured, interleaved, 20 processes +per configuration: **~16.95 on an idle machine, ~13.06 with the machine 16-way +oversubscribed**, same binary. The benchmark therefore holds the worker across +`execute()` and both attaches, so it always measures the attach-before-settle +regime: the more expensive of the two, and the one a real GUI client is in. -With the race pinned, measured on `a9cb5649` before morph#572's Parts A and C, -x86-64 Linux, clang 22.1.8 / libstdc++ 16.2.1: **17.06 allocations per local -round trip**, and 2.00 allocations per registry lookup for ids past the SSO -buffer. After: **14.06** and 0.00 per lookup — exactly 3.00 removed, in every -one of 40 processes, idle and loaded, on clang Release, clang Debug and gcc -Debug alike. Note the "before" figure is not morph#572's own 19.2: that was -taken at `4017228d`, before morph#689 changed the strand's map-node handling, -and on an ungated benchmark. See morph#572 for the per-line attribution. +With the race pinned, x86-64 Linux, clang 22.1.8 / libstdc++ 16.2.1, the +opaque-key build costs **17.06 allocations per local round trip** and 2.00 +allocations per registry lookup for ids past the SSO buffer; the transparent-key +build costs **14.06** and 0.00 per lookup — exactly 3.00 removed, in every one +of 40 processes, idle and loaded, on clang Release, clang Debug and gcc Debug +alike. A figure from an ungated benchmark, or from before the strand's map-node +handling was changed, is not comparable with either. ## Adversarial cross-socket run (`tests/qt/test_qt_websocket_adversarial.cpp`) @@ -491,7 +485,7 @@ compiler nor the linker diagnoses, since each translation unit only ever sees its own definition. Which definition the linker keeps for a given call site is link-order dependent, so the bug can pass locally and fail in CI (or the reverse), with no diagnostic pointing at the cause. This happened for -real: see issue #84 — a bare `OrderModel` stub in one file silently won over +real in this tree: a bare `OrderModel` stub in one file silently won over another file's real `OrderModel` in some builds, so the real one's `onBackendChanged()` was never invoked and its offline queue never drained. @@ -501,9 +495,10 @@ linkage (`BRIDGE_REGISTER_MODEL`, `BRIDGE_REGISTER_ACTION`) needs it — and in that case, prefer a short, file/feature-specific prefix over a generic name (`StepILOrderModel`, not `OrderModel`; see `tests/test_remote_step_interleaving.cpp`). A type declared inside a *named* namespace is also safe, since its linker -symbol is namespace-qualified (e.g. `namespace issue21::models { struct -Report { ... }; }`, used for `BRIDGE_REGISTER_MODEL`/`BRIDGE_REGISTER_ACTION` -per issue #21). +symbol is namespace-qualified (e.g. `namespace registration::models { struct +Report { ... }; }`, which is how a test that needs +`BRIDGE_REGISTER_MODEL`/`BRIDGE_REGISTER_ACTION` at namespace scope keeps its +types distinct). **Why UndefinedBehaviorSanitizer (`clang-ubsan`, `cmake/compiler_options.cmake`'s `-fsanitize=undefined`) does not catch this.** UBSan instruments individual @@ -551,12 +546,13 @@ a gate removed on 2026-09-23 against the fixtures in ## Install / export consumability (`scripts/check_install_export.sh`) Every other suite in this document builds morph from *inside* the tree, where -`include/` is on the include path because the build put it there. That is how -morph#232 survived: `cmake --install` exited 0 having installed Glaze's headers -and a working `glazeConfig.cmake` — Glaze carries its own install/export rules -and gets them for free through `FetchContent` — while installing zero morph -headers and no `morphConfig.cmake`. No CI leg installed morph, so nothing -noticed. A consumer following the standard CMake workflow got a prefix holding +`include/` is on the include path because the build put it there. That hides a +whole class of defect: `cmake --install` can exit 0 having installed Glaze's +headers and a working `glazeConfig.cmake` — Glaze carries its own +install/export rules and gets them for free through `FetchContent` — while +installing zero morph headers and no `morphConfig.cmake`. Unless a CI leg +installs morph, nothing notices. A consumer following the standard CMake +workflow then gets a prefix holding someone else's dependency and none of the library they meant to install. **CI-enforced** by a dedicated job (`install-export` in `ci.yml`), which diff --git a/docs/spec/util/datetime.md b/docs/spec/util/datetime.md index 9df5335bd..af4c52a66 100644 --- a/docs/spec/util/datetime.md +++ b/docs/spec/util/datetime.md @@ -350,7 +350,7 @@ learns only that the string was not a valid canonical UTC timestamp. string), so the journal's payload fingerprint has no reflected members to decompose and would otherwise render it as the same opaque placeholder as every other custom-codec type. That would make a retype between two of them invisible -to `replay()`'s fingerprint check (morph#245). +to `replay()`'s fingerprint check. See [`journal/journal.md`](../journal/journal.md) for the fingerprint itself. diff --git a/docs/spec/util/quantity_type.md b/docs/spec/util/quantity_type.md index 9e5e587b4..5056487d8 100644 --- a/docs/spec/util/quantity_type.md +++ b/docs/spec/util/quantity_type.md @@ -256,11 +256,11 @@ a value reads identically everywhere. There is a single formatting path and in-code references to one are references to `std::formatter`. `formatRationalDecimal` takes the numerator's magnitude through -`math::detail::absU64`, in unsigned arithmetic. It negated in `int64_t` until -morph#496, which is undefined for `INT64_MIN` — reachable because the +`math::detail::absU64`, in unsigned arithmetic. Negating in `int64_t` instead +is undefined for `INT64_MIN`, and that value is reachable here: the whole-integer `Rational{value, DecimalPlaces{n}}` constructor does not canonicalise, so the clamp that would otherwise remove the trap value never -ran. +runs. **The decimal form.** `formatRationalDecimal` renders the exact `Rational` as a fixed decimal at its **runtime `DecimalPlaces`** and then trims trailing zeros @@ -469,11 +469,11 @@ the stack, so none of the walks is recursive: The labelling walk carries a **visited set**, like the reference count. Both are walks of a DAG rather than a tree, and without one a node reachable by several paths is walked once per *path*: a derivation of 31 nodes built by - repeated `q = q + q` has 2³⁰ root-to-leaf paths and took **10.3 s** to render - 33 short lines before the set was added, 0.000 s after (morph#602). + repeated `q = q + q` has 2³⁰ root-to-leaf paths and takes **10.3 s** to render + 33 short lines without the visited set, 0.000 s with it. -Measured against the recursive code (morph#574, 8 MiB stack, clang 22.1.8 and -gcc 16.2.1): +Recursive walks of the same structure, measured with an 8 MiB stack, clang +22.1.8 and gcc 16.2.1, are why both walks are iterative: | walk | build | last depth that returned | first that segfaulted | |---|---|---|---| @@ -484,12 +484,12 @@ gcc 16.2.1): | `equation()` | gcc `-O2` | — | 40,000 | Two things follow, and both are why the flattening is a specified property of -the type rather than something left to the optimiser. Destruction *had no -failing depth at all* under `-O2`, because clang rewrites that particular -`shared_ptr` chain into a loop — so the defect crashed in Debug and survived in +the type rather than something left to the optimiser. Recursive destruction has +*no failing depth at all* under `-O2`, because clang rewrites that particular +`shared_ptr` chain into a loop — so the crash appears in Debug and hides in Release, the worst signature a defect can have. `equation()`, whose frames hold -live `Rendered` strings across the call, could not be rewritten that way: -optimisation only moved its limit, and gcc's limit was lower than clang's +live `Rendered` strings across the call, cannot be rewritten that way: +optimisation only moves its limit, and gcc's limit is lower than clang's unoptimised one. **Nodes are immutable once built.** No operation ever mutates an existing @@ -532,10 +532,10 @@ so a caller emits them verbatim), in this fixed order: Depth is unbounded (see *Provenance*), and for a while `equation()` rendered whatever depth it was handed: a 100,000-iteration running total produced four -lines whose first was **500,001 characters** of `0 + c1 + c1 + …`, built in -58.8 s (morph#582, clang 22.1.8, `-O1` under ASan+UBSan). That is not an -explanation of anything, and a caller printing it emits a single half-megabyte -line. So `equation()` takes a **step limit**: +lines whose first is **500,001 characters** of `0 + c1 + c1 + …`, built in +58.8 s (clang 22.1.8, `-O1` under ASan+UBSan). That is not an explanation of +anything, and a caller printing it emits a single half-megabyte line. So +`equation()` takes a **step limit**: ```cpp std::vector equation(std::size_t maxSteps = kDefaultEquationSteps) const; @@ -590,9 +590,8 @@ folds into one number. Two things decide it: - *A rendered formula stops being an explanation long before it stops being - affordable.* morph#574's phrasing — "an explanation 200,000 steps deep is not - an explanation" — is the defect; 100 written-out steps is already more than a - person reads, and it is two orders of magnitude above any derivation this + affordable.* An explanation 200,000 steps deep is not an explanation; 100 + written-out steps is already more than a person reads, and it is two orders of magnitude above any derivation this repository's own examples build. Setting the default where the *cost* becomes intolerable instead (thousands of steps) would keep producing output nobody can use. @@ -610,7 +609,7 @@ than a fixed constant. Two named values sit at the ends of its range: | Argument | Meaning | |---|---| | `kDefaultEquationSteps` (100) | The default. | -| `kEquationStepsUnlimited` | Write the derivation out in full — the pre-morph#582 behaviour, unbounded in output size, with the cost taken deliberately. The depth regression tests use it, since they exist to walk deeper than any limit would render. | +| `kEquationStepsUnlimited` | Write the derivation out in full — unbounded in output size, with the cost taken deliberately. The depth regression tests use it, since they exist to walk deeper than any limit would render. | | `0` | Write no step out: the one-element formatted value, the same answer a build with tracing compiled out gives. | **Rendering a derivation in full is affordable but not linear in every shape.** @@ -1304,7 +1303,7 @@ different payloads: replaying grams into a field that now means kilograms is exactly the silent corruption the journal's payload fingerprint exists to catch. A custom codec leaves no reflected members to decompose, so without this tag every `Quantity` -- and every other custom-codec type -- would render -identically (morph#245). +identically. See [`journal/journal.md`](../journal/journal.md) for the fingerprint itself. @@ -1369,7 +1368,7 @@ deliberately not attempted): the API stays callable and no nodes are allocated. The cost is measured, not estimated. A 200,000-iteration running total - (morph#574, clang 22, `-O2`, Linux): **54,056 KB** max RSS and 0.034 s with + (clang 22, `-O2`, Linux): **54,056 KB** max RSS and 0.034 s with the default, against **12,236 KB** and 0.006 s with `MORPH_QUANTITY_PROVENANCE=0` — 4.4x the memory and 5.7x the time, for a loop that adds integers. The retained chain is proportional to the loop bound, so a loop whose bound comes @@ -1377,9 +1376,9 @@ deliberately not attempted): that input. **An application that puts `Quantity` on a bulk path, and does not need `equation()` on it, should build with the macro set to `0`.** - morph#574 proposed flipping the default to `0` on those numbers. It stays at - `1`, and the reasoning is recorded here rather than left implicit, because the - two readings are both defensible and the disagreement is the interesting part: + Those numbers argue for defaulting the macro to `0`. It stays at `1`, and the + reasoning is recorded here rather than left implicit, because the two readings + are both defensible and the disagreement is the interesting part: - *For flipping.* Nobody opts into a cost they do not know about, and the price of the default is paid by every build that never calls `equation()`. @@ -1394,12 +1393,12 @@ deliberately not attempted): distinguishing feature off, silently, to buy speed on paths that can already opt out of it with one flag, trades the wrong way round. - The crash that report also found is a separate matter and was **not** left to - the toggle: a deep chain used to overflow the stack in *either* setting, which - is not an acceptable failure mode for a default, and both the destructor and - every `equation()` traversal are now iterative (see *Provenance*, "Depth is - unbounded"). If the default is ever revisited, it should be revisited on the - behaviour argument above, not on the crash — that is fixed. + Stack depth is a separate matter and is **not** left to the toggle: a deep + chain would overflow the stack in *either* setting, which is not an acceptable + failure mode for a default, so both the destructor and every `equation()` + traversal are iterative (see *Provenance*, "Depth is unbounded"). If the + default is ever revisited, it should be revisited on the behaviour argument + above, not on stack depth. - **`int64` ratio overflow for wide-range unit systems.** Conversion ratios are exact `Rational`s of 64-bit integers. A unit system spanning many orders of magnitude (pico- to tera-, say) risks overflowing a composed chained ratio — diff --git a/docs/spec/util/rational.md b/docs/spec/util/rational.md index edc284a06..ebc4ac390 100644 --- a/docs/spec/util/rational.md +++ b/docs/spec/util/rational.md @@ -188,19 +188,17 @@ site when that exact value reaches it: straight into the canonicalising constructor. - **`reciprocal`** — negates the numerator in the `numerator < 0` branch; `INT64_MIN` there overflows. -- **Rendering** (`morph::units::detail::formatRationalDecimal`) — *was* one of - these and no longer is. It negated the numerator in `int64_t` under a comment - claiming it widened first; UBSan confirmed the report. It now goes through - `detail::absU64`, which negates in unsigned arithmetic. This mattered because - the whole-integer `Rational{value, DecimalPlaces{n}}` constructor does not - canonicalise, so the clamp never ran on that path and `numerator` is public - (morph#496). -- **`canonicalise`** — **no longer one of these.** It clamps an `INT64_MIN` +- **Rendering** (`morph::units::detail::formatRationalDecimal`) — **not one of + these.** It takes the numerator's magnitude through `detail::absU64`, which + negates in unsigned arithmetic. Negating in `int64_t` there is UB that UBSan + catches, and it is reachable: the whole-integer + `Rational{value, DecimalPlaces{n}}` constructor does not canonicalise, so the + clamp never runs on that path, and `numerator` is public. +- **`canonicalise`** — **not one of these either.** It clamps an `INT64_MIN` numerator to `-INT64_MAX` (with an `error`-level log, `reportClamp`) *before* any sign flip, and computes the gcd through `detail::absU64`, which negates in - unsigned arithmetic. There is no `absoluteNumerator` local any more. Since it - is the shared sink for every constructor and operator, a value that reaches it - is safe. + unsigned arithmetic. Since it is the shared sink for every constructor and + operator, a value that reaches it is safe. The wire codec (`setWire`) also defends independently: it maps an `INT64_MIN` `num`/`den` to `-INT64_MAX` *before* constructing, so untrusted input never @@ -208,8 +206,8 @@ reaches the trap value at all. The entry points that do **not** canonicalise are where the hazard remains — the whole-integer `Rational{value, DecimalPlaces{n}}` constructor retains its -numerator verbatim, and `numerator` is a public member. See morph#496 for a -confirmed UB site reached that way. +numerator verbatim, and `numerator` is a public member. A UB site reached that +way is a confirmed, not a hypothetical, shape. ### Checked arithmetic @@ -236,17 +234,16 @@ they need no local guard to do it: `morph::log`'s helpers are themselves `noexcept` (`docs/spec/core/logger.md`, "Failure modes"), so an arithmetic operator cannot begin failing because logging failed. If the record cannot be emitted, the logging layer counts it in `morph::log::droppedLogRecords()` -rather than propagating. Both this function and `CompletionState`'s destructor -carried a local `try`/`catch` for this until morph#158 moved the guarantee to -where it belongs. - -`canonicalise` is total for the same reason. It previously negated the -numerator unguarded, so a component of `INT64_MIN` was undefined behaviour — -reachable both by constructing such a value directly and by *ordinary -arithmetic landing on it exactly* (`-INT64_MAX - 1` is a legal subtraction -whose result is `INT64_MIN`). Such a component is now clamped to `-INT64_MAX` -and logged, matching what `setWire` already did for the same values arriving -off the wire. +rather than propagating. Neither this function nor `CompletionState`'s +destructor needs a local `try`/`catch` for it: the guarantee belongs to the +logging layer, not to each of its callers. + +`canonicalise` is total for the same reason. Negating the numerator unguarded +would make a component of `INT64_MIN` undefined behaviour — reachable both by +constructing such a value directly and by *ordinary arithmetic landing on it +exactly* (`-INT64_MAX - 1` is a legal subtraction whose result is `INT64_MIN`). +Such a component is instead clamped to `-INT64_MAX` and logged, matching what +`setWire` does for the same values arriving off the wire. `checkedAdd`, `checkedSub`, `checkedMul` and `checkedDiv` return `std::expected`, yielding `RationalError::Overflow` @@ -274,11 +271,11 @@ well (`INT64_MAX/2 * 2/1` reduces to `INT64_MAX/1`). `checkedDiv` is the division member of the family, and it exists because division was the one operation with no exact-or-nothing form: `dividedBy` already returns `std::expected`, but only for the zero divisor, so a caller who -checked the result was told a clamped quotient had succeeded (morph#206). It is +checks *its* result is told a clamped quotient succeeded. `checkedDiv` is `checkedMul` against `rhs.reciprocal()` — the same operand pair `dividedBy` forms internally — and it folds both failure modes into the one channel: `DivisionByZero` propagated from `reciprocal`, `Overflow` from `checkedMul`. -`dividedBy` itself is unchanged and still saturates: `Quantity` already folds a +`dividedBy` itself saturates: `Quantity` already folds a failed division to `nullopt` (`docs/spec/error_handling.md`), and making `/` the sole operation that refuses to saturate would impose "overflow is fatal" on every caller, in-tree and out. @@ -552,7 +549,7 @@ fingerprint to decompose. Without a tag it would render as the same opaque placeholder every other custom-codec type renders as, and a field retyped between two of them -- `Rational` to `DateTime`, say -- would leave the fingerprint unchanged, so the journal would replay a recorded payload into a -type that no longer matches it (morph#245). +type that no longer matches it. See [`journal/journal.md`](../journal/journal.md) for the fingerprint itself. From e6ad0774ffffaf1ffea2dc0fe86670683585a8de Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 23 Sep 2026 20:33:21 +0200 Subject: [PATCH 2/6] examples: delete the forge annex, which the ladder no longer has a slot for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `examples/forge/` held one tracked file, a rung-8 design annex. It is not in `examples/rungs.txt` — which that file calls "the single authority" for rungs — and has no `CMakeLists.txt`, the only directory under `examples/` with neither. Its own header gates it on a decision that has passed: "building the product phases is a post-rung-4 decision", and kanban is rung 4 and shipped. Its four referrers in `examples/LADDER.md` are repaired rather than left dangling: the rung-8 table row goes, the annex range becomes 5-7, and the two sentences that named forge in prose are rewritten so they read correctly without it. `examples/kanban/README.md`'s deferral note loses a cross-reference to "forge phase 2" and states the reason directly, and `examples/IMPLEMENTATION.md`'s FTS5 escapee is described by what it is rather than by which rung would have needed it. No other link into that directory remains. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW --- examples/IMPLEMENTATION.md | 2 +- examples/LADDER.md | 15 ++- examples/forge/README.md | 192 ------------------------------------- examples/kanban/README.md | 5 +- 4 files changed, 9 insertions(+), 205 deletions(-) delete mode 100644 examples/forge/README.md diff --git a/examples/IMPLEMENTATION.md b/examples/IMPLEMENTATION.md index 67428beff..a5288a71e 100644 --- a/examples/IMPLEMENTATION.md +++ b/examples/IMPLEMENTATION.md @@ -186,7 +186,7 @@ code itself.** finding entry** — never the sqlite3 API, never a parallel helper layer. Known escapees, pre-enumerated so nobody relitigates them: conditional atomic updates with `RETURNING` (pastebin's burn-atomicity answer), FTS5 - virtual tables (forge search fallback), and WAL-read-transaction snapshot + virtual tables (a full-text search fallback), and WAL-read-transaction snapshot pinning (ledger reports). Without this tier, rung 1's *recommended* design was illegal under this rule — rule erosion or silent workarounds would have followed, both defects by the prime directive's own standard. diff --git a/examples/LADDER.md b/examples/LADDER.md index bfce2779e..10010d212 100644 --- a/examples/LADDER.md +++ b/examples/LADDER.md @@ -10,7 +10,7 @@ the Lightweight ORM throughout; clients are Qt (desktop + WASM), as in **rung 0 through rung 4** plus the no-app spikes below — that is where the unproven seams live (first WASM-remote, first shared-over-socket, offline replay, exactly-once, SQLite contention) and where reviews locate peak -findings-per-week. **Rungs 5–8 are a design annex**: their READMEs are +findings-per-week. **Rungs 5–7 are a design annex**: their READMEs are finished deliverables (requirements studies whose sharpest content the spikes convert into CI at a fraction of construction cost); building any of them is a separate decision taken *after* rung 4 with the @@ -21,9 +21,7 @@ was independently answered by the extension-bag spike (below). Both halves (7a and 7b) are now **built server-side, with no client** — see [`crm/README.md`](crm/README.md)'s "What is not built" for that gap and two smaller ones. Ledger (rung 5) remains the strongest *unbuilt* candidate — -the only other annex rung with a genuinely app-shaped core; forge's -framework content still ships as its load script against synthetic models. -The program's +the only annex rung left with a genuinely app-shaped core. The program's product is **findings fixed, not apps shipped** — see [`FINDINGS.md`](FINDINGS.md) for what counts, triage, the fix budget, exit criteria, and the demotion policy. @@ -83,11 +81,10 @@ later rungs consume earlier answers (5 reuses 4's cascade-journaling answer, | 5* | [`ledger`](ledger) | [Firefly III](https://github.com/firefly-iii/firefly-iii), [Actual Budget](https://github.com/actualbudget/actual) | Exact `Rational` arithmetic under invariants, multi-currency, sync-philosophy benchmark | | 6 | [`lims`](lims) | [SENAITE](https://github.com/senaite/senaite.core), [InvenTree](https://github.com/inventree/InvenTree), [ODK Central](https://github.com/getodk/central) | Unit algebra, versioned schema-driven forms, offline entities with conflict detection | | 7 | [`crm`](crm) | [EspoCRM](https://github.com/espocrm/espocrm), [Tryton](https://github.com/tryton/tryton), [Frappe](https://github.com/frappe/frappe) | Metadata-driven forms, dynamic logic, per-field authz; **7b** (gated): runtime custom fields | -| 8* | [`forge`](forge) | [Gogs](https://github.com/gogs/gogs), [Gitea/Forgejo](https://github.com/go-gitea/gitea), GitLab architecture | Everything at once: orgs/permissions, notifications at scale, webhooks, out-of-protocol sidecars | \* = design annex: README is the deliverable; construction is a post-rung-4 -decision (ledger under construction; lims built; forge → load script; crm -built server-side, no client — its own defining framework question was +decision (ledger under construction; lims built; crm built server-side, no +client — its own defining framework question was independently resolved by the extension-bag spike, which is why it carries no `*` here; 7b's go/no-go gate was passed on that answer). @@ -221,8 +218,8 @@ committed scope is rungs 0–4 (+ spikes): ~8–10 bank-equivalents, a findings-per-week. Deferral decisions recorded in the rung READMEs: kanban defers automation rules and attachments to a "later" section (ledger needs only the cascade *decision*, writable from a spike); the annex rungs keep -their internal gates (7a/7b, forge phase 3 per-item) for whenever they are -green-lit. The **fault-injection wire proxy and the strand interleaver are +their internal gates (7a/7b) for whenever they are green-lit. The +**fault-injection wire proxy and the strand interleaver are pulled forward to rung 0–1** (round-7: they outperform whole rungs on finding yield; scheduling them at rung 4 delayed the program's highest-value instruments behind three rungs of CRUD). diff --git a/examples/forge/README.md b/examples/forge/README.md deleted file mode 100644 index 038ac3dc3..000000000 --- a/examples/forge/README.md +++ /dev/null @@ -1,192 +0,0 @@ -# forge — rung 8 of the [application ladder](../LADDER.md) - -**Status: design annex** ([round-7 program decision](../LADDER.md)) — this -README is the deliverable; the rung's *framework* content (polling at -500–2,000 sockets, unbounded notification instances, epoch resync, -hardened-config latency) ships earlier as the **forge load script against -synthetic models**; building the product phases is a post-rung-4 decision. -A software forge — the GitLab class: organizations, -teams, repositories, issues, labels, milestones, notifications, wiki, pull -requests with reviews, webhooks, CI status. The ladder's ceiling: every -subsystem and every known framework limit at once, at multi-client scale. - -## Reference implementations - -- **[Gitea](https://github.com/go-gitea/gitea) / - [Forgejo](https://codeberg.org/forgejo/forgejo)** (Go, MIT) — the anchor. - Decisive facts, verified: - - **SQLite is a first-class supported database** — a full forge runs on - morph's persistence tier. - - Even Gitea's own UI **treats push as an optional enhancement over - polling**: notification counts poll (SSE optional and distrusted, see - [gitea#25661](https://github.com/go-gitea/gitea/issues/25661)), CI - runners **poll** `FetchTask` - ([#24543](https://github.com/go-gitea/gitea/issues/24543) to change that - is still open), and the CI log view polls a JSON endpoint - ([#33606](https://github.com/go-gitea/gitea/issues/33606)). A - request/response-only forge is therefore *precedented*, not a - compromise. - - Architecture to study: layered monolith `routers → services → models - (XORM) → modules`; background work behind a unified queue abstraction - (persistable-channel/LevelDB — analogous to morph's SQLite offline - queue); `hook_tasks` table for webhook delivery + retry. - Overview: - Note also: a `git push` over SSH **bypasses morph entirely**, yet repo - viewers must see the new branch on their next poll — the post-receive - hook needs the server-side internal-dispatch seam established in - [`bookmarks`](../bookmarks); the drift test is "push via sidecar, assert - a polling client converges." -- **[Gogs](https://github.com/gogs/gogs)** (Go, MIT) — Gitea's ancestor, - deliberately minimal, single binary + SQLite: the best small-codebase read - for "what is the true minimum forge". -- **[Zulip's events system](https://zulip.readthedocs.io/en/stable/subsystems/events-system.html)** - — the notification transport blueprint: per-client server-side event - queues, register-with-snapshot then incremental `getEventsSince`, queue - GC + full-state resync on expiry. Proves an entire real-time product - ships on request/response alone. This rung scales the pattern introduced - in [`polls`](../polls) to many clients per user across many entities. -- **GitLab itself** — the architecture *lesson*, not a code reference: Rails - keeps typed app logic; **Workhorse** (large/slow transfers) and **Gitaly** - (all git object access, gRPC) bypass it. The shape to copy: typed actions - in morph; bytes in sidecars. -- [Pagure](https://github.com/Pagure/pagure) — curiosity worth knowing: - issue/PR metadata stored as JSON *in git*, i.e. metadata history = git - history — a cousin of morph's replayable journal. - -## What to implement - -Build order follows verified complexity ranking; each phase ships usable. - -**Phase 1 — the tracker (morph sweet spot).** -Models: `OrgModel`, `RepoModel` (shared instance per repo), `IssueModel` -(shared instance per issue), `NotificationModel` (per user). - -Two review-mandated design rules up front: **key models by immutable ids, -never by mutable attributes** — "instances never change key" is load-bearing -in the shared-instance design, and repo rename/transfer (a table-stakes -forge feature this rung must include) collides head-on with a name-keyed -`RepoModel`; and **per-user notification instances are unbounded** — N users -each pinning a live shared instance forever collides with -`LimitPolicy::maxLiveModels` and the absence of idle eviction; the load -script measures instances/memory vs. connected users deliberately, to -motivate an eviction policy [framework gap to expose]. - -1. Users, orgs, teams; repo create/settings; permission matrix - (owner/admin/write/read) via `IAuthorizer` — Gitea's permission checks - transliterated. -2. Issues: CRUD, comments, labels, milestones, assignees, state machine. - **Issue history comes free from the journal** — Gitea maintains a - `comment` row type per event; here the journal *is* that table. -3. Notifications: fan-out-on-write to per-user rows; clients poll unread - counts (exactly what Gitea does); Zulip-pattern event queues for list - deltas — including the Zulip design's *expiry half*: **event-queue GC - and server-restart epochs**. A client holding `lastEventId` across a - restart must detect the epoch change and full-resync; without it the - load test silently measures the wrong thing after the first restart. -4. Search: SQL `LIKE`/FTS5 fallback (Gitea ships a DB fallback too); - indexing pipelines are out of scope. - -**Phase 2 — git enters (the sidecar).** - -5. Repo browsing: tree/blob/commit/branch/log/README rendering. Git object - access lives in a **sidecar module shelling out to git** (Gitea's - `modules/git` approach) exposed as read-only actions; large blobs and - raw-file/archive downloads go over a plain HTTP endpoint next to the - WebSocket server — **the Gitaly/Workhorse lesson: bytes never travel the - JSON action protocol.** Clone/push (smart HTTP/SSH) is served by that - sidecar entirely outside morph. -6. Wiki: a git repo of markdown reusing the same sidecar. - -**Phase 3 — collaboration machinery (the hard 20%).** - -7. Webhooks: config as CRUD actions; delivery as a **durable outbound job - queue in SQLite** (Gitea's `hook_tasks`) with retry + dead-letter — - the background-job pattern from [`bookmarks`](../bookmarks) at - production shape. -8. Pull requests + reviews: diff computation in the sidecar, paginated diff - actions (response-size bounds get measured here), review threads - anchored to diff positions, approve/request-changes state machine. - **Merge is the submit→poll job idiom** from [`ledger`](../ledger): - `SubmitMerge` → job id → poll status (no `Completion` chaining, no - cancellation — this is where those limits show). -9. CI status: an external runner **polls** `FetchTask` (Gitea's actual - protocol), posts status/logs up; the UI polls `GetLogsSince(offset)` for - log tailing — incremental delivery within request/response, the honest - stress test of one-callback-per-outcome. - -## morph subsystems exercised - -All of them, at scale: authorization at real granularity, shared instances -(repo/issue) with many concurrent viewers, journal as product feature -(issue history, audit), event-queue polling under N clients × M -subscriptions (the scale test for no-push), durable background queues, the -sidecar boundary for everything binary. - -## Expected strain points (the point of the rung) - -- **Polling at scale**: notification freshness vs. server load. Review - quantified the meaningful load: **500–2,000 concurrent sockets at - ~1 poll/s** — the ceiling is the single Qt thread that receives every - frame and marshals every reply, not the worker pool; "dozens of clients" - finds nothing. Measure p99 poll latency vs. N, including during a - `closeGracefully` drain, plus the rate-limiter interaction (dropped - frames hang unwrapped completions — the rung-3 helper's timeout is - load-bearing here). -- **Payload bounds**: large diffs/file lists through JSON actions; - pagination as a first-class action idiom — including **diff-cursor - staleness under force-push** (cursors and review comments anchored to - positions that no longer exist; put a diff id/epoch in the cursor). -- **Long operations**: merge/CI without composable completions or - cancellation — the submit→poll idiom's limits. Test **duplicate - `SubmitMerge`** (double-click → two jobs racing on one repo's git lock) - and **client disconnect mid-poll** (the job registry must be - server-scoped, not connection-scoped: the job completes and is - re-pollable from a new connection). -- **Permission revocation mid-session**: a demoted user's attached - `IssueModel`/`RepoModel` handlers must go fully inert — reads included — - not just fail new registrations (kanban's revocation answer at forge - scale). -- **The protocol boundary**: keeping git bytes, archives, and log streams - cleanly outside the action model without the two worlds drifting; webhook - deliveries signed via the [`vetted_hmac`](../vetted_hmac) pattern. -- **Right-to-erasure vs. permanent journal** (written deliverable): the - journal never prunes; GDPR-class user deletion against an immutable audit - trail is an unresolved framework question (rotation exists, redaction - does not). Document the position. - -## Security posture — the hardened-configuration demonstration - -Delivery review found the ladder tested security features piecemeal but -never *composed* them; this rung closes that. The forge server binary's -default configuration is the full `docs/spec/security.md` checklist: TLS -(`tlsVerifyingConfig`/`tlsPinnedConfig`), `MORPH_REQUIRE_VETTED_HMAC=ON` -with a `vetted_hmac` adapter, a `SigningAuthorizer` subclass overriding -**both** `authorizeRegister` and `authorizeInstance`, full `LimitPolicy`, -full server bounds, and `hello` version negotiation — and the **load script -runs against this hardened config** (the limiter, in-flight caps, and TLS -change the latency curve; measuring only the unbounded server measures a -configuration the spec says never to deploy). - -## Phase gating (delivery review) - -Phases 1–2 constitute a shippable forge-lite. Phase 3's items (webhooks, -PRs/reviews, CI protocol) each get an individual go/no-go, like crm's 7b — -phase 3 is effectively a second product and must not be entered as a block. - -## Explicit non-goals - -Sub-second collaborative editing (Etherpad-class OT — genuinely requires -push), federation, code search indexing, and **public-internet exposure / -red-teaming** — but note the hardened *configuration* is in scope, per the -security section above. - -## Definition of done - -- Two orgs, several repos, issues + PRs + reviews end-to-end from Qt - desktop and WASM clients against the remote backend, SQLite storage. -- A demo runner executes a job and the UI tails its log by polling. -- Webhook deliveries survive a server restart (durable queue) and retry. -- A load script sweeping to 500–2,000 polling connections (process-pool - clients per [`../TESTING.md`](../TESTING.md)), with p99 latency and - live-instance/memory measurements written up in this folder — including - a run across a server restart (epoch resync) and a graceful drain. diff --git a/examples/kanban/README.md b/examples/kanban/README.md index 6876748e1..a7b67c757 100644 --- a/examples/kanban/README.md +++ b/examples/kanban/README.md @@ -303,9 +303,8 @@ renderer mistypes array-valued schema keys when `schema` is assigned as a ## Steps 6 and 8: implemented -Steps 6 (automation rules) and 8 (attachments) were originally deferred to a -"later" bucket (each is independently large, and the attachments answer is -duplicated at forge phase 2) — both are now implemented. Automation rules +Steps 6 (automation rules) and 8 (attachments) are implemented, having been +deferred to a "later" bucket first because each is independently large. Automation rules (tag add/remove, triggered on move-to-column) are scoped to the two mutation kinds this rung's schema actually supports; the README's own illustrative "assign to closer" example is not implemented, since no "closer" concept From 5d8443ca02b1712ebc75ac0843103eb5db7eb40c Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 23 Sep 2026 20:35:40 +0200 Subject: [PATCH 3/6] examples: delete FINDINGS.md, which now duplicates four live homes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every section of `examples/FINDINGS.md` has somewhere else to live, so keeping it is duplication that drifts: what gets recorded is AGENTS.md's filing bar, the promotion rule is AGENTS.md's "same example defect in a second place", triage dispositions are the `triage-issue` skill's verdict labels, and identity and citation are GitHub issues. It also directed readers to `docs/findings/`, which does not exist. Its one live fact was the "Promoted findings" record: five rungs had accumulated seven near-identical copies of the same idempotency check-then-set table, which is why `morph::offline::IReplayLedger` exists. `docs/spec/offline/offline.md` already carried that argument — the rung count, the call-site count, why storage stays app-side, and the conformance suite — so rather than duplicate it, that paragraph gains the five rung names and the history is dropped. Referrers under `examples/` are rewritten rather than unlinked, since most are instructions to a reader: `IMPLEMENTATION.md` and `LADDER.md` point at AGENTS.md's filing bar; `kanban/README.md`, `pastebin/README.md`, `common/wasm_spike/README.md`, `crm/README.md` and `crm`'s `lead_dto.hpp` state the rule they were citing instead of citing it. The pastebin section explaining why a flat finding sequence was abandoned keeps the argument — a number that outlives its target resolves to the wrong thing — and drops the account of the migration. The `docs/superpowers/` references are left alone: those are plan and spec documents recording work as it was done, and a passing mention of the pipeline in a record of the past is not an instruction to anyone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW --- docs/spec/offline/offline.md | 15 +-- examples/FINDINGS.md | 140 ---------------------- examples/IMPLEMENTATION.md | 11 +- examples/LADDER.md | 8 +- examples/common/wasm_spike/README.md | 14 +-- examples/crm/README.md | 5 +- examples/crm/include/crm/dto/lead_dto.hpp | 6 +- examples/kanban/README.md | 4 +- examples/pastebin/README.md | 29 ++--- 9 files changed, 40 insertions(+), 192 deletions(-) delete mode 100644 examples/FINDINGS.md diff --git a/docs/spec/offline/offline.md b/docs/spec/offline/offline.md index 13d9c63da..269247a10 100644 --- a/docs/spec/offline/offline.md +++ b/docs/spec/offline/offline.md @@ -194,8 +194,9 @@ it, and a host must pick one: The two paragraphs above define the *contract* — dedup on a shared key. The mechanism is `morph::offline::IReplayLedger` (`include/morph/offline/replay_ledger.hpp`), and it lives in the framework -because five rungs across seven call sites otherwise each hand-write the same -op-id-keyed table answering "has this already been applied?": +because five rungs — `bookmarks`, `crm`, `kanban`, `ledger`, `lims` — across +seven call sites otherwise each hand-write the same op-id-keyed table +answering "has this already been applied?": ```cpp struct IReplayLedger { @@ -224,15 +225,15 @@ model's *already-open* mapper/transaction (see `BookmarksReplayLedger` in `examples/bookmarks/src/models/bookmark_model.cpp` for the reference shape — a file-local class, not its own header: it has exactly one consumer, and a header with no translation unit of its own has no `compile_commands.json` -entry, which cost the first version of this exactly the tooling problem it -now avoids) — and only the contract, plus +entry, which is what clang-tidy needs to see it at all) — and only the +contract, plus `tests/replay_ledger_conformance.hpp` to check an implementation against it, is promoted. **No base class for consumers.** A model holds or is handed an -`IReplayLedger&`; it never derives from one — every occurrence found before -promotion was a free function or a plain member, and a base-class design would -have been un-adoptable by all of them. +`IReplayLedger&`; it never derives from one — every occurrence in the rungs is +a free function or a plain member, and a base-class design would be +un-adoptable by all of them. **One mechanism, two response families.** The two shapes the seven occurrences split into — response-replay (kanban's/ledger's `StoreTransaction`, which diff --git a/examples/FINDINGS.md b/examples/FINDINGS.md deleted file mode 100644 index 6a68e4654..000000000 --- a/examples/FINDINGS.md +++ /dev/null @@ -1,140 +0,0 @@ -# The finding pipeline - -The ladder's product is **findings fixed, not apps shipped**. The holistic -(round-7) review found the "framework-gap ledger" load-bearing in every -governing document yet defined nowhere — so success would have defaulted to -the only thing definitions-of-done measure: apps built. This document -defines the pipeline. - -## What a finding is - -A finding is one of: - -1. **A minimal failing test** checked into `tests/` (preferred — a finding - that cannot be expressed as a failing test is not yet understood), or -2. **A spec-cited impossibility** — a short write-up citing the spec/header - that shows the capability structurally cannot exist today (e.g. "no - holder-swap primitive for in-place undo on a shared instance"). - -Each finding is a **GitHub issue**. Open it with the repro or spec citation, -what should happen, and what happens instead — and state up front: - -- **subsystem** — `core`, `bridge`, `backend`, `offline`, `journal`, `forms`, - `units`, `session`, `qt` or `wire`, mirrored by the issue's `area:` label. -- **severity** — blocker, major, minor, or paper-cut. -- **source** — the rung, spike or review round that produced it. -- **test** — the path to the failing test, or "spec-cited". - -The disposition is the issue's `triage:` label, and the issue's own state is -whether the finding is still open. - -## Identity and citation - -The finding's id is its **GitHub issue number**, assigned by GitHub. Cite it as -`#NNN`, and in prose name the subject as well as the number — "the async -shared/keyed attach finding (#207)". A bare number is precise but tells a -reader nothing about whether it still says what the citing text claims. - -**Findings used to be files** under `docs/findings/`, named -`-NNN-.md` and namespaced by rung. That directory has been -retired and its remaining entries migrated to issues. Two pieces of reasoning -from it are worth keeping, because they are why the file scheme existed and -why it stopped: - -- **A single global sequence does not survive parallel branches**, and did not: - two disjoint series were allocated independently and both merged, so - `001`–`004` came to mean one thing on one branch and something else on - another. Every one of those commits followed the obvious rule ("take the next - unused number") correctly — the rule was the problem, not the discipline. - *A rule that every violation already satisfies is not a control.* Rung - namespacing (`r4-001`, `r5-001`) fixed that; issue numbers, allocated - centrally, remove the question entirely. -- **Ids are never reused, and a stale citation should look stale.** A citation - that outlives its target must fail to resolve rather than silently point at - something else. Closed issues stay addressable, which is strictly better than - a deleted file — but the hazard survives the move: several code comments were - found citing `finding 004`, which had been *renamed* rather than deleted and - so resolved, silently, to an unrelated finding. - -Historical citations of the form `docs/findings/NNN` or a bare `finding NNN` -resolve to nothing and should be rewritten to name the issue or state the fact -directly. - -## Triage and dispositions - -Every finding gets a disposition within one triage pass (the repo owner -decides; the ladder never self-triages): - -- **fix-scheduled** — a framework change is planned; the finding's test - stays red-listed (tagged `[finding]`, excluded from the green gate) until - the fix lands, then joins the regression suite permanently. -- **documented-limitation** — the behavior is accepted and the relevant - `docs/spec/` file is updated to say so; the test asserts the *documented* - behavior and turns green. -- **wontfix** — recorded with rationale. -- **promoted** — [`IMPLEMENTATION.md`](IMPLEMENTATION.md)'s promotion rule - fired (a third rung independently built the same app-layer answer to a - framework gap): the answer moved into `include/morph`, with its full docs - tax, and every consuming rung may re-express itself over the promoted - interface. The rule's other exit is **documented-limitation** under the - name "app-layer by design" — see [morph#197](https://github.com/LASTRADA-Software/morph/issues/197)'s - disposition (`docs/spec/offline/offline.md`, "Disposition: app-layer by - design") for that branch. See "Promoted findings" below for the record. - -## Fix budget - -Discovery already outruns repair (the six detail review rounds produced -~40 findings before any rung code existed). The binding ratio: **for every -month of rung construction, at least one week of framework-fix time** is -spent draining `fix-scheduled` findings — including their full docs tax -(spec file, Doxygen, pinned facts). If the open `fix-scheduled` count grows -two rungs in a row, rung construction pauses. - -## Rung exit criteria - -A rung is **done** when: - -1. its README's design questions are resolved in writing, -2. every named strain test exists — passing, or filed as a finding, -3. its findings are triaged (no `open` dispositions left). - -**Feature completeness is explicitly not an exit criterion.** A rung may -exit half-built; Kanboard's remaining thirty tables exert no gravity here. - -## Back-fill - -The ~40 findings from review rounds 1–7 (preserved in the session review -reports and folded into the governing docs) are the program's entire -current output. Back-filling them as issues — failing tests where -expressible — is **the first task of rung 0**, before any app -code. The four LADDER prerequisites and the forms-gap ledger entries are -findings 001–0NN. - -## Promoted findings - -The record the **promoted** disposition above points to — the promotion rule -has fired once so far: - -- **Op-id/exactly-once replay ledger.** Seven hand-written, near-identical - copies of the same idempotency-check-then-set table had accumulated across - five rungs (`bookmarks`, `crm`, `kanban`, `ledger`, `lims`) with no - `FINDINGS.md` entry recording it, three rungs past the promotion rule's own - trigger point ([morph#226](https://github.com/LASTRADA-Software/morph/issues/226)). - Promoted as `morph::offline::IReplayLedger` - (`include/morph/offline/replay_ledger.hpp`) plus a conformance suite - (`tests/replay_ledger_conformance.hpp`); storage stays app-side, per rung, - because `include/morph` has no dependency on any SQL library and the - check-then-set must commit in the same transaction as the write it guards - (see the interface's own doc comment). `bookmarks` is migrated onto it - (`BookmarksReplayLedger` in `examples/bookmarks/src/models/bookmark_model.cpp`); the - other four rungs are follow-up work, not required by this promotion. - -## Demotion policy (the ladder must never tax the framework) - -Once a rung exits, it **demotes** in per-PR CI to compile-only plus one -smoke test; its full matrix moves to the weekly tier (see -[`TESTING.md`](TESTING.md), "Build system and CI") instead of running on -every push; its 100%-coverage gate freezes at its exit commit and does not -bind future framework PRs. The instrument built to motivate framework -change must never become the reason a framework fix is too expensive to -land. diff --git a/examples/IMPLEMENTATION.md b/examples/IMPLEMENTATION.md index a5288a71e..e1f506f6f 100644 --- a/examples/IMPLEMENTATION.md +++ b/examples/IMPLEMENTATION.md @@ -8,7 +8,7 @@ these applications exist to **stress-test morph**, not to be products. **The prime directive: every line of custom code that morph (or Lightweight) could have provided is a defect in the stress test.** If the framework can't provide it, that inability is a *finding* — record it per -[`FINDINGS.md`](FINDINGS.md), don't quietly code around it. +[`AGENTS.md`](../AGENTS.md)'s filing bar, don't quietly code around it. **Before you design around a Lightweight limitation, read [`docs/LIGHTWEIGHT-CONSTRAINTS.md`](../docs/LIGHTWEIGHT-CONSTRAINTS.md)** — one @@ -27,12 +27,11 @@ tax, drawn from the fix budget) or **explicitly dispositioned in the spec as app-layer by design**. Without this rule the ladder ends with a shadow framework living in `examples/common` — which would be the program's biggest finding, permanently unfiled. The op-id ledger example is no longer -hypothetical: it fired three rungs past its own trigger point and was -promoted as `morph::offline::IReplayLedger` -([morph#226](https://github.com/LASTRADA-Software/morph/issues/226); see -[`FINDINGS.md`](FINDINGS.md), "Promoted findings", and +hypothetical: five rungs hand-wrote the same op-id table across seven call +sites before it was promoted as `morph::offline::IReplayLedger` — see [`docs/spec/offline/offline.md`](../docs/spec/offline/offline.md)'s -`IReplayLedger` section for the disposition). +`IReplayLedger` section for what it promotes and what it deliberately leaves +app-side. ## 1. Models are the application diff --git a/examples/LADDER.md b/examples/LADDER.md index 10010d212..50e928712 100644 --- a/examples/LADDER.md +++ b/examples/LADDER.md @@ -13,8 +13,8 @@ replay, exactly-once, SQLite contention) and where reviews locate peak findings-per-week. **Rungs 5–7 are a design annex**: their READMEs are finished deliverables (requirements studies whose sharpest content the spikes convert into CI at a fraction of construction cost); building any of -them is a separate decision taken *after* rung 4 with the -[finding pipeline](FINDINGS.md) scoreboard in hand — **crm (rung 7) was +them is a separate decision taken *after* rung 4, with what the built rungs +have found in hand — **crm (rung 7) was green-lit for construction on 2026-08-28** by direct decision rather than waiting on that scoreboard review, once its own defining framework question was independently answered by the extension-bag spike (below). Both halves @@ -23,8 +23,8 @@ was independently answered by the extension-bag spike (below). Both halves smaller ones. Ledger (rung 5) remains the strongest *unbuilt* candidate — the only annex rung left with a genuinely app-shaped core. The program's product is **findings fixed, not apps shipped** — see -[`FINDINGS.md`](FINDINGS.md) for what counts, triage, the fix budget, exit -criteria, and the demotion policy. +[`AGENTS.md`](../AGENTS.md)'s filing bar for what a rung's defect is worth +recording and what it is not. **The no-app spikes** (start immediately, in parallel with rungs 0–1; each files findings, none builds an app): diff --git a/examples/common/wasm_spike/README.md b/examples/common/wasm_spike/README.md index 9dbea8ac9..c5d04f9c7 100644 --- a/examples/common/wasm_spike/README.md +++ b/examples/common/wasm_spike/README.md @@ -52,11 +52,10 @@ prerequisites, the two most likely failure modes and their owning findings: true` — re-open the *async shared/keyed attach* finding even though this spike deliberately avoids the *shared* path; if the *plain* async path also aborts, that is a new, more severe finding (the plain path was supposed to - already be WASM-safe per `[issue26]`'s native tests) — file it as a GitHub - issue per [`examples/FINDINGS.md`](../../FINDINGS.md), titled for the gap - ("plain async registration aborts under WASM") with `severity: blocker`, - and this rung's exit criteria (per `examples/FINDINGS.md`) are **not met** - until it is at least triaged. + already be WASM-safe per `[issue26]`'s native tests) — it is a framework + defect, so file it per [`AGENTS.md`](../../../AGENTS.md)'s filing bar, + titled for the gap ("plain async registration aborts under WASM"), and this + rung is not done until it is at least triaged. - **"connected" logs but no "result=" ever appears.** The action dispatch itself is hanging — check whether `Completion` needs the *client-side execute deadline* finding's @@ -65,9 +64,8 @@ prerequisites, the two most likely failure modes and their owning findings: If either failure mode reproduces, do **not** silently work around it in this spike — record it as a finding (per the two bullets above) and mark rung 0's -Task 10 complete anyway with a "documents a real blocker" note; `FINDINGS.md`'s -rung exit criteria explicitly allow a rung to exit with findings still -`open`/`fix-scheduled`, just not un-triaged. +Task 10 complete anyway with a "documents a real blocker" note. A rung may +exit with findings still open; it may not exit with findings untriaged. If the Emscripten configure itself fails before either failure mode above becomes observable (for example, `find_package(Qt6 COMPONENTS WebSockets diff --git a/examples/crm/README.md b/examples/crm/README.md index 8378c9568..15875aa21 100644 --- a/examples/crm/README.md +++ b/examples/crm/README.md @@ -181,9 +181,8 @@ schemas/layouts). Build order (each step is a usable milestone): they did before this rung existed; the moment any role is assigned, the account switches to enforced mode and an *unlisted* principal is then implicitly `Viewer`. A real deployment would pair this with a - roles-backfill step at account-creation time (out of scope here — see - `docs/findings/` convention for where that would be filed as a - productionization gap, not a bug in this rung). + roles-backfill step at account-creation time (out of scope here, and a + productionization gap rather than a bug in this rung). - **Per-field enforcement has no framework hook and needed two new pieces**, matching the round-5 ground truth this section's "Expected strain points" already named: (1) `crm::gui::updateAccountSchemaJsonFor` diff --git a/examples/crm/include/crm/dto/lead_dto.hpp b/examples/crm/include/crm/dto/lead_dto.hpp index e7064f1c4..cef4956c0 100644 --- a/examples/crm/include/crm/dto/lead_dto.hpp +++ b/examples/crm/include/crm/dto/lead_dto.hpp @@ -104,11 +104,11 @@ struct ListLeadsResult { /// concurrent conversions blocking on nested completions can starve the pool /// outright — no thread is ever free to run the nested `execute()` that /// would unblock them (see `test_convert_lead.cpp`'s pool-starvation test, -/// which demonstrates the naive alternative deadlocking a small pool, and -/// `docs/findings/` for why this is genuinely new ground: no sanctioned +/// which demonstrates the naive alternative deadlocking a small pool). This +/// is genuinely new ground: no sanctioned /// internal-client seam exists yet, and the only same-model cascade /// precedent, `kanban::BoardModel::evaluateRules`, does not extend -/// mechanically to three different models' tables). +/// mechanically to three different models' tables. /// /// This also resolves — not just relocates — the "three per-model journal /// entries carry no causal link" limit `LADDER.md`'s Journal honesty section diff --git a/examples/kanban/README.md b/examples/kanban/README.md index a7b67c757..99f002d9c 100644 --- a/examples/kanban/README.md +++ b/examples/kanban/README.md @@ -316,9 +316,7 @@ reads by the caller's project role (not bearer-token validity alone). ## Findings -Filed as GitHub issues per [`FINDINGS.md`](../FINDINGS.md) (this rung's -findings were originally `r4-001`/`r4-002` under the retired -`docs/findings/` directory): +Filed as GitHub issues, per [`AGENTS.md`](../../AGENTS.md)'s filing bar: - [#343](https://github.com/LASTRADA-Software/morph/issues/343) — the replay-attempt budget cannot tell an undelivered replay from a diff --git a/examples/pastebin/README.md b/examples/pastebin/README.md index 05ced8a7f..2bf6a4275 100644 --- a/examples/pastebin/README.md +++ b/examples/pastebin/README.md @@ -379,27 +379,20 @@ the `BridgeHandler` `AppContext::onReady()` hands it. `offline/file_offline_queue.hpp` and `session/session_auth.hpp` was closed framework-side. - **On the finding numbers this section used to cite.** The ten findings above - were filed under a flat global sequence that no longer exists. - That sequence was first namespaced by rung (`-NNN-.md`) - because a flat sequence did not survive parallel branches — two disjoint - series were allocated independently and both merged, so the same number came - to mean different things on different branches. The `docs/findings/` - directory has since been retired altogether in favour of GitHub issues (see - [`../FINDINGS.md`](../FINDINGS.md)), so the bare numbers this rung's prose - used to carry (`017`, `018`, `021`, `023`, `026`) resolve to nothing. They - have been replaced throughout by a description of the gap and a pointer to - the code that closes it, which is what a reader actually needs and what - survives a renumbering. No finding has been invented to make an old citation - resolve. + **Why this section names gaps rather than numbering them.** A bare finding + number is precise and tells a reader nothing about whether it still says + what the citing text claims, and a flat sequence does not survive parallel + branches: two disjoint series allocated independently both merge, and the + same number then means different things on different branches. Each gap + below is therefore described, with a pointer to the code that closes it — + which is what a reader needs and what survives a renumbering. ### Known gaps, stated rather than smoothed over -- **Findings triage complete.** [`../FINDINGS.md`](../FINDINGS.md)'s "Rung - exit criteria" makes a rung done when (1) its README's design questions are - resolved in writing, (2) every named strain test exists — passing or filed - as a finding, and (3) its findings are triaged (no `open` dispositions - left). All three are now met: of the ten findings this rung owned or +- **Findings triage complete.** A rung is done when (1) its README's design + questions are resolved in writing, (2) every named strain test exists — + passing or filed as a finding, and (3) its findings are triaged. All three + are met: of the ten findings this rung owned or inherited, eight have since been fixed framework-side (the framework fixes are described inline throughout this README and this rung's own source comments, not re-listed here). `db_fault_fixture.hpp`'s store-error From 155b12c9f969731a1ee8fa920f4d1fe971248828 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 23 Sep 2026 20:55:39 +0200 Subject: [PATCH 4/6] scripts+.github+cmake: gate comments that state what the gate measures, not which ticket asked for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 356 tracker references across the gate configuration — ci.yml's 132 the largest share — replaced by the constraint each one stood for. The pattern throughout is the same: a comment saying "this exists because morph#NNN" is rewritten to say what goes wrong without the line, which is what a reader editing it needs and what survives the ticket being closed. Every measured block is kept intact and in the present tense: ci.yml's 302x polling-step swing and its parallel-ctest timings, cmake/tsan.supp's second_deadlock_stack line counts and its 0.275s/0.273s cost, the allowlist's five-run branch censuses, compiler_options.cmake's UBSan-recovers demonstration. Past-tense narration around them is recast as the condition the number belongs to. **User-visible strings changed** — text a person reads on a failure, which AGENTS.md's rule covers as much as a comment: - `.github/workflows/ci.yml` — the clang-tidy filter's "findings in your code (morph#753)" warning. - `cmake/DepCache.cmake` — `morph_declare_dep`'s FATAL_ERROR, and the `FETCHCONTENT_SOURCE_DIR_*` cache docstring. - `cmake/morph_add_rung.cmake` — the semicolon-in-journey-name FATAL_ERROR. - `cmake/compiler_options.cmake` — the warning-sentinel FATAL_ERROR, three coverage cache-sharing messages, and the coverage-manifest FATAL_ERROR. - `scripts/check_branch_coverage.py` — the "contributes no branch records" failure. - `scripts/check_install_export.sh`, `scripts/check_ctest_name_collisions.sh`, `scripts/check_coverage_objects.sh`, `scripts/check_coverage_roots.sh`, `scripts/check_catch2_pin.sh` — one message each. - `scripts/test_check_sanitizer_instrumentation.sh` — two failure strings. Two of those are asserted on by a self-test. `check_branch_coverage.py`'s own `--self-test` keyed two cases on the literal `morph#403` appearing in the message it was checking; both now key on `contributes no branch records`, and the self-test passes. That is the failure mode this kind of edit has: a message is a contract with whatever reads it. `scripts/branch_partial_allowlist.json`: every measured figure and every "what would retire this entry" clause stays. What goes is the provenance — CI job ids, branch names, commit SHAs — and the account of one entry being deleted and restored, which is rewritten as what it actually teaches: without `-fprofile-update=atomic` a coverage run can report that disjunct as taken and fail the gate, and the entry is right anyway. Left alone deliberately: the ctest `-E "OomInjector|morph#108"` filter in ci.yml, which selects by test name, and `morph690_fixture_marker` in a sanitizer test fixture. Both are code that contains a ticket-shaped token; renaming them is a behaviour change, not a comment change. The `net audit finding #10` labels in the allowlist are that audit's own vocabulary, not tracker references, and sit beside `#6`, `#7`, `ST1` and `BK2`. Verified: all four workflows parse as YAML; all 14 distinct `scripts/...` paths they name resolve; `branch_partial_allowlist.json` and `scenario/coverage_allowlist.json` parse as JSON; the three edited Python modules parse; and the self-tests of check_branch_coverage, check_sanitizer_instrumentation, check_coverage_profiles, check_coverage_objects, check_ctest_name_collisions, check_coverage_roots, check_automoc_includes and check_tidy_suppression_scope all pass, as does `check_catch2_pin.sh .`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW --- .github/workflows/ci.yml | 694 +++++++++--------- .github/workflows/docs.yml | 2 +- .github/workflows/wasm-ladder.yml | 23 +- cmake/CompileCache.cmake | 6 +- cmake/DepCache.cmake | 16 +- cmake/compiler_options.cmake | 92 +-- cmake/morph_add_rung.cmake | 36 +- cmake/morph_demote_interface_includes.cmake | 2 +- cmake/tsan.supp | 32 +- scripts/aggregate_lcov_branches.py | 4 +- scripts/branch_partial_allowlist.json | 28 +- scripts/check_automoc_includes.sh | 5 +- scripts/check_branch_coverage.py | 113 ++- scripts/check_catch2_pin.sh | 23 +- scripts/check_coverage_objects.sh | 22 +- scripts/check_coverage_profiles.sh | 4 +- scripts/check_coverage_roots.sh | 20 +- scripts/check_ctest_name_collisions.sh | 4 +- scripts/check_install_export.sh | 8 +- scripts/check_sanitizer_instrumentation.sh | 8 +- scripts/check_tidy_suppression_scope.sh | 11 +- scripts/coverage.sh | 26 +- scripts/ladder_rungs.sh | 6 +- scripts/scenario/README.md | 49 +- scripts/scenario/coverage_allowlist.json | 8 +- scripts/scenario/morph_scenario.py | 19 +- scripts/scenario/mutate_scenario.py | 2 +- scripts/scenario/run_scenarios.py | 14 +- ...-card-through-its-whole-lifecycle.scenario | 4 +- ...ht-is-checked-against-the-session.scenario | 18 +- ...king-without-a-session-is-refused.scenario | 14 +- .../sign-in-create-tag-and-read-back.scenario | 2 +- ...-must-be-opened-before-it-answers.scenario | 18 +- ...sign-in-create-project-open-board.scenario | 6 +- .../ledger/accounts-of-every-kind.scenario | 2 +- .../bootstrap-a-book-over-the-wire.scenario | 11 +- ...pen-account-transact-report-close.scenario | 6 +- .../store-list-and-undo-an-entry.scenario | 22 +- .../submit-a-report-and-poll-it.scenario | 4 +- .../ledger/two-books-are-isolated.scenario | 22 +- .../pastebin/expire-then-read.scenario | 4 +- .../wire-kinds-and-typeid-refusals.scenario | 3 +- scripts/test_check_automoc_includes.sh | 2 +- scripts/test_check_catch2_pin.sh | 4 +- scripts/test_check_coverage_objects.sh | 4 +- scripts/test_check_coverage_profiles.sh | 6 +- scripts/test_check_coverage_roots.sh | 8 +- scripts/test_check_ctest_name_collisions.sh | 4 +- scripts/test_check_install_export.sh | 8 +- .../test_check_sanitizer_instrumentation.sh | 18 +- scripts/test_check_tidy_suppression_scope.sh | 2 +- 51 files changed, 711 insertions(+), 758 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c13f59130..5c7e6879e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,19 +86,15 @@ env: FASTCACHE_PREFETCH_GROUP: "${{ github.run_id }}-${{ github.job }}" CLANG_VERSION: "22" # The Catch2 the `clang-tidy` job analyses against, through the - # clang-tidy-diff.py run inside it. (Until morph#645 this sentence named - # clang-tidy-diff.py where the job id belongs -- the same substitution - # morph#637 was.) Not a - # version this + # clang-tidy-diff.py run inside it. Not a version this # workflow installs -- `apt-get install -y catch2` takes whatever # ubuntu-24.04 ships -- but a record of what that has been measured to be, # which the "Assert the Catch2 this job analyses against" step below reads # back off the runner and fails on if it has moved. # - # It is worth recording because it is load-bearing and invisible, but NOT - # for the reason recorded here until morph#777. The check the version - # actually decides is `bugprone-chained-comparison`, not - # `readability-function-cognitive-complexity`. Measured on 68a30bcc with + # It is worth recording because it is load-bearing and invisible. The check + # the version decides is `bugprone-chained-comparison`, not + # `readability-function-cognitive-complexity`. Measured with # clang-tidy 22.1.8, one TEST_CASE of twelve `REQUIRE`s, both releases # reached the same way (-isystem, i.e. as a system header, which is how this # runner and a workstation both reach theirs): @@ -133,12 +129,12 @@ env: # increased to 2 # # -- the last line is the lambda in `REQUIRE(pumpUntil([&app] { ... }))`, - # spelled in the test file, and it is why that finding survived the system - # filter. So there is no local/CI asymmetry left to pin on this check, and - # morph#666's "exit 0 where this job exits 1" was really morph#776: a local - # `git diff -U0 HEAD` that analysed zero files. + # spelled in the test file, and it is why that finding survives the system + # filter. So there is no local/CI asymmetry to pin on this check: a local run + # that exits 0 where this job exits 1 is almost always a `git diff -U0 HEAD` + # that analysed zero files, not a Catch2 difference. # - # Which artefact this names, checked rather than assumed (morph#777): + # Which artefact this names, checked rather than assumed: # ubuntu-24.04's package is 3.4.0-1build1 and it ships /usr/lib/cmake/Catch2, # so `find_package(Catch2 CONFIG QUIET)` at CMakeLists.txt:557 SUCCEEDS on # this runner and the FetchContent v3.8.1 fallback never runs -- reproduced @@ -322,14 +318,14 @@ jobs: sudo apt-get update -q sudo apt-get install -y ninja-build catch2 # Download, check, then execute -- not - # `wget -qO- https://apt.llvm.org/llvm.sh | sudo bash` (morph#681). + # `wget -qO- https://apt.llvm.org/llvm.sh | sudo bash`. # `wget -q` writes no error document, so on an HTTP 4xx/5xx it exits # 8 having written zero bytes. `bash` then reads an empty script, # does nothing, and exits 0 -- and GitHub runs `run:` under `bash -e` # without pipefail, so the pipeline's status is bash's, not wget's. - # The step therefore reported success having installed no compiler, - # and the leg went red several steps later at Configure, naming a - # missing compiler rather than the download that never happened. + # That step reports success having installed no compiler, and the leg + # goes red several steps later at Configure, naming a missing + # compiler rather than the download that never happened. # # Measured against apt.llvm.org itself, on a path that 404s: # @@ -339,7 +335,7 @@ jobs: # curl: (22) The requested URL returned error: 404 # exit=22 # - # Same three lines as the `Install sccache` steps (morph#672), so the + # Same three lines as the `Install sccache` steps, so the # repository has one download idiom rather than two: `--fail` so the # download reports its own failure, a file so nothing downstream runs # when it does, and `test -s` for the 200-with-empty-body case that @@ -378,28 +374,26 @@ jobs: - name: Install sccache if: "!contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted')" run: | - # `--fail`, and a file rather than a pipe into tar (morph#672). On - # 2026-09-21 both GCC legs of PR #671 died in this step with + # `--fail`, and a file rather than a pipe into tar. Piped, a host + # that serves an error page instead of a tarball kills the step with # # gzip: stdin: not in gzip format # tar: Child returned status 1 # tar: Error is not recoverable: exiting now # - # The download host had served something that is not a tarball -- - # an error page -- and plain `curl` reports an HTTP 4xx/5xx as a - # *successful* transfer of that page, exit 0, straight down the - # pipe. So the only diagnostic anyone saw named the decompressor, - # and the event that actually happened (the download failed, with a - # status) appeared nowhere in the log. The step could not be - # salvaged by `set -o pipefail` either: GitHub runs `run:` under - # `bash -e`, without pipefail, so the pipeline's status is tar's - # regardless of what curl did. + # because plain `curl` reports an HTTP 4xx/5xx as a *successful* + # transfer of that page, exit 0, straight down the pipe. The only + # diagnostic anyone sees names the decompressor, and the event that + # actually happened -- the download failed, with a status -- appears + # nowhere in the log. `set -o pipefail` cannot salvage it either: + # GitHub runs `run:` under `bash -e`, without pipefail, so the + # pipeline's status is tar's regardless of what curl did. # # `--fail` makes curl exit non-zero and print `curl: (22) The # requested URL returned error: 503`; writing to a file means tar # never runs at all when it does. This is not a retry policy and it # does not make the outage less likely -- it makes the log name the - # thing that happened, which is the half of morph#672 that needs no + # thing that happened, which is the half of the problem that needs no # CI-wide decision. The rest of that class -- a retry policy, or a # marker that distinguishes "the environment failed" from "the # change failed" in the check list, and the apt/PPA outages that @@ -492,10 +486,11 @@ jobs: # per-commit entry, and with 16 cache-writing legs at 1 GB each one # generation already exceeds GitHub's 10 GB per-repo budget -- so # legs evicted each other and entries rarely survived to be reused. - # Restricting writes to master keeps morph#109's property (only one - # branch ever writes, so no branch can delete another's entry) while - # collapsing the generations that actually blow the budget. Pull - # requests still restore, read-only, and get the full benefit. + # Restricting writes to master keeps the property that makes the cache + # usable at all (only one branch ever writes, so no branch can delete + # another's entry) while collapsing the generations that actually blow + # the budget. Pull requests still restore, read-only, and get the full + # benefit. - name: Save sccache if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') && !contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted') uses: actions/cache/save@v4 @@ -536,7 +531,7 @@ jobs: sudo apt-get install -y ninja-build catch2 libsqlite3-dev # --fail, a file, and a non-empty check -- never `wget -qO- | sudo # bash`: see linux-compilers' identical install step for why the piped - # form reported success having installed nothing (morph#681). + # form reports success having installed nothing. curl -sSL --fail -o /tmp/llvm.sh https://apt.llvm.org/llvm.sh test -s /tmp/llvm.sh sudo bash /tmp/llvm.sh ${{ env.CLANG_VERSION }} @@ -564,8 +559,7 @@ jobs: run: | # `--fail` and a file rather than a pipe into tar: see # linux-compilers' identical Install sccache step for why an - # unchecked download reported itself as "not in gzip format" - # (morph#672). + # unchecked download reports itself as "not in gzip format". curl -sSL --fail -o /tmp/sccache.tar.gz \ https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ @@ -574,7 +568,7 @@ jobs: # morph::net and the SQLite offline queue are opt-in, but they are also # where the memory/threading/UB risk actually lives (raw sockets, an I/O # thread, a hand-rolled frame reader, a C API). Left off, the sanitizers - # silently skipped them. QML, the fuzzers, and the ladder stay out of + # silently skip them. QML, the fuzzers, and the ladder stay out of # this matrix entirely — they are covered by linux-coverage and # linux-all-features, and "a GUI stack under TSan is mostly noise". # @@ -593,16 +587,16 @@ jobs: run: cmake --build --preset ${{ matrix.preset }} # A sanitizer job whose binaries are not instrumented runs the whole suite - # and learns nothing, while reporting success (morph#542). CI already - # asserted this, but only over the ladder's binaries and only for - # `__asan_` -- an assertion that is vacuous on the tsan and ubsan legs, - # which is the same "control that measures nothing" it exists to prevent. - # This walks what ctest will actually run and keys the expected symbol on - # the preset, so none of the three legs can pass on a blind build. + # and learns nothing, while reporting success. Asserting this over one + # job's binaries, or for `__asan_` alone, is vacuous on the tsan and + # ubsan legs -- the same "control that measures nothing" it exists to + # prevent. This walks what ctest will actually run and keys the expected + # symbol on the preset, so none of the three legs can pass on a blind + # build. # # No `QT_QPA_PLATFORM: offscreen` here, unlike the identical step in # bank-sanitizers, kanban-tsan and ladder-sanitizers, and the absence is - # deliberate rather than an omission (morph#691). The sweep's first act is + # deliberate rather than an omission. The sweep's first act is # `ctest --show-only=json-v1`, which *executes* every DISCOVERY_MODE # PRE_TEST binary to enumerate its cases; a Qt-linked one aborts headless # and takes the whole listing with it. This matrix's Configure above sets @@ -629,8 +623,8 @@ jobs: # exception_ptr's, and the COW string holding the message -- both # decremented inside an uninstrumented libstdc++.so.6, so TSan # gets no happens-before edge between the last thread to read a - # shared exception's what() and the thread whose release frees it - # (morph#476). The file itself carries the measured evidence and + # shared exception's what() and the thread whose release frees it. + # The file itself carries the measured evidence and # says why each of the three frames it names still resolves # against the runner's stripped libstdc++. # @@ -639,18 +633,18 @@ jobs: # # `second_deadlock_stack=1` is colon-separated *into this value*, not # a second `TSAN_OPTIONS:` key -- a second key silently replaces the - # first and drops the suppressions file, which is morph#688's failure - # one spelling over. It makes TSan print, for a lock-order inversion, + # first and drops the suppressions file, leaving the job green and + # unsuppressed. It makes TSan print, for a lock-order inversion, # the stacks where the *already-held* mutexes were taken. Measured on # a two-mutex inversion under clang 22.1.8: 36 lines -> 55, the extra # 19 being two `Mutex Mn previously acquired by the same thread here:` # stacks that name the acquiring function and line. Without it TSan # prints only `Hint: use TSAN_OPTIONS=second_deadlock_stack=1 to get # more informative warning message` -- advice nobody can take after - # the fact, because morph#578 and morph#717 are intermittent and the - # run that fires is the only evidence that will ever exist. No - # measurable runtime cost: 2M lock acquisitions under TSan took a - # median 0.275s without and 0.273s with (morph#736). + # the fact, because the races this job catches are intermittent and + # the run that fires is the only evidence that will ever exist. No + # measurable runtime cost: 2M lock acquisitions under TSan take a + # median 0.275s without and 0.273s with. TSAN_OPTIONS: suppressions=${{ github.workspace }}/cmake/tsan.supp:second_deadlock_stack=1 run: | # morph::testkit::OomInjector (tests/oom_injector.cpp) overrides @@ -660,20 +654,20 @@ jobs: # under either sanitizer rather than linking a second definition # beside theirs. # - # This comment used to say the link failed with "multiple definition - # of `operator new(unsigned long)'", and called that confirmed in CI. - # It is not what happens (morph#718): the link succeeds, and the - # tests fail at *runtime* instead, because OomInjector's constructor - # throws when its overrides are compiled out -- + # The link does *not* fail with "multiple definition of + # `operator new(unsigned long)'", which is the obvious guess. It + # succeeds, and the tests fail at *runtime* instead, because + # OomInjector's constructor throws when its overrides are compiled + # out -- # # OomInjector: unusable under ASan/TSan (operator new/delete # overrides are compiled out) # # -- which is precisely what a ctest-level exclusion handles, and is - # why the exclusion below works at all. With it bypassed, morph#719's - # lane measured six failures on each of the two legs (the five - # OomInjector cases plus the morph#108 one). Deleting the -E filter - # on the strength of the old wording would turn both legs red. + # why the exclusion below works at all. Measured with the filter + # bypassed: six failures on each of the two legs, the five + # `OomInjector` cases plus the one whose name the second alternative + # matches. Deleting the -E filter turns both legs red. # # Excluded by test name on exactly these two legs; every other CI leg # (plain clang/gcc, Windows, ubsan, coverage) runs these tests @@ -749,7 +743,7 @@ jobs: sudo apt-get install -y ninja-build catch2 libsqlite3-dev # --fail, a file, and a non-empty check -- never `wget -qO- | sudo # bash`: see linux-compilers' identical install step for why the piped - # form reported success having installed nothing (morph#681). + # form reports success having installed nothing. curl -sSL --fail -o /tmp/llvm.sh https://apt.llvm.org/llvm.sh test -s /tmp/llvm.sh sudo bash /tmp/llvm.sh ${{ env.CLANG_VERSION }} @@ -803,8 +797,7 @@ jobs: run: | # `--fail` and a file rather than a pipe into tar: see # linux-compilers' identical Install sccache step for why an - # unchecked download reported itself as "not in gzip format" - # (morph#672). + # unchecked download reports itself as "not in gzip format". curl -sSL --fail -o /tmp/sccache.tar.gz \ https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ @@ -844,7 +837,7 @@ jobs: LLVM_PROFILE_FILE="build/clang-coverage/%p.profraw" ctest --preset clang-coverage # Deliberately left on the implicit `success()`, unlike the three test - # steps morph#618 moved to `!cancelled()` (ladder-tests' scenario corpus, + # steps that carry `!cancelled()` (ladder-tests' scenario corpus, # ladder-sanitizers' Qt transport suites, linux-all-features' fuzz-replay # verification). This is a decision, not an oversight, and it is written # down so it is not "fixed" later: this step and the two below it consume @@ -865,8 +858,8 @@ jobs: # other *.lcov/coverage.* file under the build tree too (its own # log says so: "Found 5 coverage files to report") -- including # coverage.lcov.raw, the *pre-aggregation* file - # aggregate_lcov_branches.py's morph#93/#92 fixes rewrite away - # from, plus unrelated fetched-dependency fixtures + # aggregate_lcov_branches.py rewrites away from, plus unrelated + # fetched-dependency fixtures # (_deps/nlohmann_json-src/.../coverage.test). Codecov then merges # all of them, silently re-introducing every record `coverage.lcov` # was built to remove. `files:` alone does not disable that scan -- @@ -874,14 +867,11 @@ jobs: disable_search: true token: ${{ secrets.CODECOV_TOKEN }} - # No `if:` guard. This step used to carry `if: matrix.preset == - # 'clang-coverage'`, left behind when this job was split out of - # linux-sanitizers' matrix (see this job's own header comment). The job has - # no matrix, so `matrix.preset` evaluates to the empty string, the - # condition is false on every run, and the HTML report has never been - # uploaded -- a step that is skipped and a step that succeeded are rendered - # the same way, which is why nothing reported it. Found while fixing - # morph#403. + # No `if:` guard, and none may be added that names `matrix`. This job has + # no matrix, so `matrix.preset` evaluates to the empty string and any + # condition testing it is false on every run -- the step is then skipped + # forever, and a skipped step and a successful one are rendered the same + # way, so nothing reports it. - name: Upload coverage HTML uses: actions/upload-artifact@v4 with: @@ -890,7 +880,7 @@ jobs: # llvm-cov show emits one page per source file across include/morph, # examples/common and every rung -- a few thousand small files, which # upload-artifact pays per-file overhead on. Bounded rather than kept - # forever, since the step now actually runs on every coverage build. + # forever, since the step runs on every coverage build. retention-days: 14 # Cumulative hit/miss for this leg. Without it the cache is @@ -901,14 +891,14 @@ jobs: if: always() run: sccache --show-stats || true - # Save only on a push to master. Every run used to write a new - # per-commit entry, and with 16 cache-writing legs at 1 GB each one - # generation already exceeds GitHub's 10 GB per-repo budget -- so - # legs evicted each other and entries rarely survived to be reused. - # Restricting writes to master keeps morph#109's property (only one - # branch ever writes, so no branch can delete another's entry) while - # collapsing the generations that actually blow the budget. Pull - # requests still restore, read-only, and get the full benefit. + # Save only on a push to master. Writing a new per-commit entry from + # every run does not work here: with 16 cache-writing legs at 1 GB each, + # one generation exceeds GitHub's 10 GB per-repo budget, so legs evict + # each other and entries rarely survive to be reused. Restricting writes + # to master keeps the property that makes the cache usable at all (only + # one branch ever writes, so no branch can delete another's entry) while + # collapsing the generations that blow the budget. Pull requests still + # restore, read-only, and get the full benefit. - name: Save sccache if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') uses: actions/cache/save@v4 @@ -928,14 +918,13 @@ jobs: # requires -- to keep it a minimal, fast, TSan-clean addition rather than # pulling every rung's Qt Quick/QML code into the sanitizer matrix. # - # (History: an earlier version of this test drove the same scenario through - # BackendRig{Mode::Local, ...}, whose Mode::Local unconditionally - # constructs a real morph::qt::QtExecutor for client-facing callback - # delivery -- morph#128 found 165 ThreadSanitizer warnings bottoming out in - # genuine Qt-internal frames reached through it, undetectable as real bugs - # or false positives from outside a TSan-instrumented Qt build. Rewriting - # the test to never construct a QtExecutor at all sidesteps the ambiguity - # entirely rather than resolving it.) + # The test must not reach a QtExecutor, and the constraint is load-bearing + # rather than stylistic: driving the same scenario through + # BackendRig{Mode::Local, ...} constructs a real morph::qt::QtExecutor for + # client-facing callback delivery, and that produces 165 ThreadSanitizer + # warnings bottoming out in genuine Qt-internal frames -- undetectable as + # real bugs or false positives from outside a TSan-instrumented Qt build. + # Never constructing one sidesteps the ambiguity rather than resolving it. kanban-tsan: name: Kanban / ThreadSanitizer runs-on: ubuntu-24.04 @@ -973,7 +962,7 @@ jobs: unixodbc-dev libsqliteodbc libyaml-cpp-dev libzip-dev libgl1-mesa-dev # --fail, a file, and a non-empty check -- never `wget -qO- | sudo # bash`: see linux-compilers' identical install step for why the piped - # form reported success having installed nothing (morph#681). + # form reports success having installed nothing. curl -sSL --fail -o /tmp/llvm.sh https://apt.llvm.org/llvm.sh test -s /tmp/llvm.sh sudo bash /tmp/llvm.sh ${{ env.CLANG_VERSION }} @@ -1004,8 +993,7 @@ jobs: run: | # `--fail` and a file rather than a pipe into tar: see # linux-compilers' identical Install sccache step for why an - # unchecked download reported itself as "not in gzip format" - # (morph#672). + # unchecked download reports itself as "not in gzip format". curl -sSL --fail -o /tmp/sccache.tar.gz \ https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ @@ -1039,7 +1027,7 @@ jobs: run: cmake --build --preset clang-tsan # The same assertion linux-sanitizers and ladder-sanitizers make, for the - # same reason (morph#542): this job's whole claim is that kanban's stress + # same reason: this job's whole claim is that kanban's stress # test ran under ThreadSanitizer, and an uninstrumented binary makes that # claim while proving nothing. Keyed on `__tsan_`, not `__asan_` — an # ASan-only assertion on a TSan leg is itself a control that measures @@ -1047,7 +1035,7 @@ jobs: # # QT_QPA_PLATFORM=offscreen, matching the Build step above and the Test # step below, because this step sits between them and enumerates the same - # binaries they do (morph#691). The sweep begins with `ctest + # binaries they do. The sweep begins with `ctest # --show-only=json-v1`, and listing a DISCOVERY_MODE PRE_TEST suite *runs* # its binary. Nothing this job builds is both Qt-linked and PRE_TEST # today -- ladder_kanban_tests is POST_BUILD (cmake/morph_add_rung.cmake), @@ -1055,7 +1043,7 @@ jobs: # -- which is why this step is green without the variable and not why it # is safe. Changing one word to PRE_TEST on any Qt-linked rung suite would # make this step fail with `ctest listed no tests`, naming neither Qt nor - # the display; that is morph#690, which cost three sessions over two days. + # the display -- a diagnostic that points nowhere near its cause. - name: Every ctest binary is instrumented env: QT_QPA_PLATFORM: offscreen @@ -1073,16 +1061,16 @@ jobs: - name: Test (kanban's TSan-tagged stress test only) env: QT_QPA_PLATFORM: offscreen - # The suppression file linux-sanitizers' Test step already passes, - # missing here (morph#542). Both entries are libstdc++ refcounts for - # a shared exception, decremented inside an uninstrumented - # libstdc++.so.6 (morph#476); the evidence is in cmake/tsan.supp. + # The same suppression file linux-sanitizers' Test step passes. Both + # entries are libstdc++ refcounts for a shared exception, decremented + # inside an uninstrumented libstdc++.so.6; the evidence is in + # cmake/tsan.supp. # Without it this leg is a coin toss on a false positive that the # other TSan leg already knows is one — and the failure mode of a # known false positive is that the next real one is waved past. # # `second_deadlock_stack=1` for the reason the linux-sanitizers Test - # step above gives at length (morph#736), and appended to the same + # step above gives at length, and appended to the same # value rather than added as a second `TSAN_OPTIONS:` key, which # would silently drop the suppressions file this leg was given in the # first place. @@ -1097,14 +1085,14 @@ jobs: if: always() run: sccache --show-stats || true - # Save only on a push to master. Every run used to write a new - # per-commit entry, and with 16 cache-writing legs at 1 GB each one - # generation already exceeds GitHub's 10 GB per-repo budget -- so - # legs evicted each other and entries rarely survived to be reused. - # Restricting writes to master keeps morph#109's property (only one - # branch ever writes, so no branch can delete another's entry) while - # collapsing the generations that actually blow the budget. Pull - # requests still restore, read-only, and get the full benefit. + # Save only on a push to master. Writing a new per-commit entry from + # every run does not work here: with 16 cache-writing legs at 1 GB each, + # one generation exceeds GitHub's 10 GB per-repo budget, so legs evict + # each other and entries rarely survive to be reused. Restricting writes + # to master keeps the property that makes the cache usable at all (only + # one branch ever writes, so no branch can delete another's entry) while + # collapsing the generations that blow the budget. Pull requests still + # restore, read-only, and get the full benefit. - name: Save sccache if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') uses: actions/cache/save@v4 @@ -1113,53 +1101,46 @@ jobs: key: sccache-clang-tsan-${{ github.sha }} # ── Linux: the bank example under UndefinedBehaviorSanitizer ────────── - # Until morph#679, no sanitizer leg built the bank example at all. Measured - # on 5fc5e788: linux-sanitizers, kanban-tsan, ladder-sanitizers and valgrind - # set zero MORPH_BUILD_BANK_* flags, against eight places elsewhere in this - # file that set MORPH_BUILD_BANK_EXAMPLE=ON. Bank is not a rung (it is absent - # from examples/rungs.txt and never calls morph_add_rung(), for the reasons - # that file gives), so MORPH_BUILD_LADDER=ON does not reach it either, and - # nothing in those four jobs' comments argued for excluding it -- it was an - # option nobody turned on rather than a decision anybody made. + # No other sanitizer leg builds the bank example: linux-sanitizers, + # kanban-tsan, ladder-sanitizers and valgrind set zero MORPH_BUILD_BANK_* + # flags, against eight places elsewhere in this file that set + # MORPH_BUILD_BANK_EXAMPLE=ON. Bank is not a rung (it is absent from + # examples/rungs.txt and never calls morph_add_rung(), for the reasons that + # file gives), so MORPH_BUILD_LADDER=ON does not reach it either. This job is + # the only instrumented build bank gets. # - # The cost of that was paid in morph#663: an out-of-range double -> - # std::int64_t conversion in examples/bank/gui/controllers/Format.hpp, on the - # path of every amount typed into the bank GUI, found by a lane reading the - # code. Rebuilding that header as it stood before the fix, in this job's - # exact configuration, bank_gui_tests exits 1 with + # What that covers, stated exactly, because the obvious claim is too strong. + # An out-of-range double -> std::int64_t conversion in + # examples/bank/gui/controllers/Format.hpp -- on the path of every amount + # typed into the bank GUI -- exits bank_gui_tests 1 here with # # examples/bank/gui/controllers/Format.hpp:76:38: runtime error: # 9.2e+19 is outside the range of representable values of type 'long' # - # -- but only because morph#663's fix also added the test that calls - # parseMinor with such a value. Measured too: with the pre-morph#663 header - # *and* the pre-morph#663 test set, this leg is green. The sanitizer gap was - # real and is what this job closes; it was not on its own what let morph#663 - # survive, and this job's reach is bounded by how much of bank the suites - # actually drive. + # but only because a test calls parseMinor with such a value. Measured: with + # that test absent, the same defective header leaves this leg green. A + # sanitizer leg reaches only what the suites drive, and this one's reach is + # bounded by how much of bank they drive. # # Why a job of its own rather than a flag on linux-sanitizers' clang-ubsan - # leg -- the shape the ticket proposed. Two measured reasons. (1) That leg - # builds no Qt, and its own comment reserves the matrix against GUI stacks; - # bank's GUI is where the UB was, so covering it means adding Qt there. (2) - # Cold, cacheless, 12 cores, clang 22.1.8: the leg's current shape - # (core + net + offline_sqlite, no Qt) is 36s configure / 144s build over 144 - # ninja edges, and this job's shape is 64s / 391s over 287. Folding one into - # the other roughly triples the slowest leg of a three-leg matrix, whose - # duration is then the matrix's. Split out, the three existing legs are - # untouched -- their flags are not changed by morph#679 at all -- and this - # runs beside them. Same precedent, and the same argument, as kanban-tsan - # above. + # leg. Two measured reasons. (1) That leg builds no Qt, and its own comment + # reserves the matrix against GUI stacks; bank's GUI is where this class of + # UB lives, so covering it means adding Qt there. (2) Cold, cacheless, 12 + # cores, clang 22.1.8: that leg's shape (core + net + offline_sqlite, no Qt) + # is 36s configure / 144s build over 144 ninja edges, and this job's shape is + # 64s / 391s over 287. Folding one into the other roughly triples the slowest + # leg of a three-leg matrix, whose duration is then the matrix's. Split out, + # the three existing legs are untouched and this runs beside them. Same + # argument as kanban-tsan above. # - # ubsan rather than asan: UBSan is the sanitizer that diagnoses morph#663's - # class, and an ASan run over a Qt GUI needs the detect_leaks=0 and - # suppression story ladder-sanitizers already carries, which this job would - # have to acquire before it could be believed. Bank under ASan is worth - # having and is not closed by this job. + # ubsan rather than asan: UBSan is the sanitizer that diagnoses the + # conversion class above, and an ASan run over a Qt GUI needs the + # detect_leaks=0 and suppression story ladder-sanitizers already carries, + # which this job would have to acquire before it could be believed. Bank + # under ASan is worth having and is not covered here. # - # The first bill was measured before this landed, per morph#646 (84 findings) - # and morph#656 (97): turning bank on and instrumenting every one of its - # targets produced **zero** UBSan findings. bank_tests (145 assertions in 21 + # The bill was measured: turning bank on and instrumenting every one of its + # targets produces **zero** UBSan findings. bank_tests (145 assertions in 21 # cases), bank_gui_tests (19 in 5) and bank_gui_qml_tests (32 in 2) all pass # clean, and so does the full 1696-test suite of this configure. Nothing is # suppressed here and there is no allowlist entry. @@ -1193,7 +1174,7 @@ jobs: unixodbc-dev libsqliteodbc libyaml-cpp-dev libzip-dev libgl1-mesa-dev # --fail, a file, and a non-empty check -- never `wget -qO- | sudo # bash`: see linux-compilers' identical install step for why the piped - # form reported success having installed nothing (morph#681). + # form reports success having installed nothing. curl -sSL --fail -o /tmp/llvm.sh https://apt.llvm.org/llvm.sh test -s /tmp/llvm.sh sudo bash /tmp/llvm.sh ${{ env.CLANG_VERSION }} @@ -1223,18 +1204,17 @@ jobs: run: | # `--fail` and a file rather than a pipe into tar: see # linux-compilers' identical Install sccache step for why an - # unchecked download reported itself as "not in gzip format" - # (morph#672). + # unchecked download reports itself as "not in gzip format". curl -sSL --fail -o /tmp/sccache.tar.gz \ https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache # MORPH_BUILD_BANK_GUI=ON, not MORPH_BUILD_BANK_EXAMPLE alone: the - # controllers and Format.hpp -- where morph#663 was -- live in - # bank_gui_lib, which only exists when the GUI option adds gui/. Without - # it this job would instrument bank_lib and the ORM and miss the half of - # bank the defect was in. No MORPH_BUILD_LADDER: bank is not a rung, so + # controllers and Format.hpp -- the numeric-conversion surface this job + # exists for -- live in bank_gui_lib, which only exists when the GUI + # option adds gui/. Without it this job instruments bank_lib and the ORM + # and misses the half of bank that does the arithmetic. No MORPH_BUILD_LADDER: bank is not a rung, so # it would add every rung's tree for nothing. - name: Configure (clang-ubsan, bank example + GUI) run: | @@ -1253,18 +1233,18 @@ jobs: # own Build step. In this configure that is morph_qt_tests # (tests/qt/CMakeLists.txt:91); it is *not* bank's three suites, which # are PRE_TEST and therefore enumerate at ctest time instead. The step - # below is where that matters (morph#690). + # below is where that matters. - name: Build env: QT_QPA_PLATFORM: offscreen run: cmake --build --preset clang-ubsan # The same assertion linux-sanitizers, ladder-sanitizers and kanban-tsan - # make (morph#542). It is not a formality here: before morph#679, bank's - # targets carried no apply_sanitizers() call except ladder_bank_server's, - # and this sweep is what says so rather than letting the leg report a - # clean bank run over uninstrumented binaries. Measured on the tree as it - # stood, with bank configured on and the AF_SANITIZER blocks absent: + # make. It is not a formality here: bank's targets need an + # apply_sanitizers() call of their own, and this sweep is what says so + # rather than letting the leg report a clean bank run over uninstrumented + # binaries. Measured with bank configured on and the AF_SANITIZER blocks + # absent: # # ::error::check_sanitizer_instrumentation: 3 of 9 ctest binaries # are not ubsan-instrumented @@ -1273,8 +1253,7 @@ jobs: # blocks in place the same sweep reports 9 of 9. # # QT_QPA_PLATFORM=offscreen, and this is the step that cannot do without - # it (morph#690 — this job was red on every run from the day it landed, - # here). The sweep's first act is `ctest --show-only=json-v1`, and ctest + # it. The sweep's first act is `ctest --show-only=json-v1`, and ctest # is exactly where bank's PRE_TEST discovery runs: listing the tests # *executes* `bank_gui_qml_tests --list-tests`, whose main constructs a # QGuiApplication (examples/common/testkit/testkit_main.cpp) before @@ -1282,9 +1261,9 @@ jobs: # aborts; Catch2's CatchAddTests.cmake raises a message(FATAL_ERROR) on a # nonzero discovery, and ctest then exits 8 having printed no JSON at all # — not bank's entries missing, the entire listing, every other suite - # included. The sweep correctly refused to pass having examined nothing - # (morph#675's floor), so the symptom was the guard and the cause was - # this missing line. The Test step below already declares the same value, + # included. The sweep correctly refuses to pass having examined nothing, + # so the symptom is the guard and the cause is the missing variable -- + # a diagnostic worth expecting. The Test step below declares the same value, # which is the point: this sweep's subject is the binaries that step will # run, so it has to enumerate them in that step's environment. # @@ -1347,7 +1326,7 @@ jobs: run: sccache --show-stats || true # Save only on a push to master, for the cache-budget reason every other - # leg's identical step gives (morph#109). + # leg's identical step gives. - name: Save sccache if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') uses: actions/cache/save@v4 @@ -1396,8 +1375,7 @@ jobs: run: | # `--fail` and a file rather than a pipe into tar: see # linux-compilers' identical Install sccache step for why an - # unchecked download reported itself as "not in gzip format" - # (morph#672). + # unchecked download reports itself as "not in gzip format". curl -sSL --fail -o /tmp/sccache.tar.gz \ https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ @@ -1413,7 +1391,7 @@ jobs: - name: Build run: cmake --build --preset gcc-debug - # ── moc output that climbs out of the build tree (issue #372) ──── + # ── moc output that climbs out of the build tree ───────────────── # Runs here rather than alongside the repo's other lint gates because # what it inspects is generated: it needs an AUTOMOC target to have # actually been built. The checker's own self-test needs none of that and @@ -1435,14 +1413,14 @@ jobs: if: always() run: sccache --show-stats || true - # Save only on a push to master. Every run used to write a new - # per-commit entry, and with 16 cache-writing legs at 1 GB each one - # generation already exceeds GitHub's 10 GB per-repo budget -- so - # legs evicted each other and entries rarely survived to be reused. - # Restricting writes to master keeps morph#109's property (only one - # branch ever writes, so no branch can delete another's entry) while - # collapsing the generations that actually blow the budget. Pull - # requests still restore, read-only, and get the full benefit. + # Save only on a push to master. Writing a new per-commit entry from + # every run does not work here: with 16 cache-writing legs at 1 GB each, + # one generation exceeds GitHub's 10 GB per-repo budget, so legs evict + # each other and entries rarely survive to be reused. Restricting writes + # to master keeps the property that makes the cache usable at all (only + # one branch ever writes, so no branch can delete another's entry) while + # collapsing the generations that blow the budget. Pull requests still + # restore, read-only, and get the full benefit. - name: Save sccache if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') uses: actions/cache/save@v4 @@ -1474,9 +1452,8 @@ jobs: # Not because `echo | grep` can lose a status today -- `echo` does # not fail -- but because this block decides whether a whole job # runs, and the next edit to it is the one that adds a pipeline - # that can (morph#730). The checker that enforced the same rule on - # every other `run:` block was removed with the meta-gates, so this - # is a convention now rather than something CI holds you to. + # that can. Nothing enforces this rule across the file's other `run:` + # blocks, so it is a convention rather than something CI holds you to. set -o pipefail if [ "${{ github.event_name }}" = "pull_request" ]; then base="${{ github.event.pull_request.base.sha }}" @@ -1489,13 +1466,13 @@ jobs: fi changed=$(git diff --name-only "$base" HEAD) # The pattern is generated from examples/rungs.txt, never written - # here. It used to be a hand-copied rung alternation, and it had - # drifted a full rung behind: it stopped at kanban, so a change - # confined to examples/ledger/ (rung 5) or examples/lims/ (rung 6) - # matched nothing and skipped this job -- silently, because a filter - # that matches nothing looks exactly like one that correctly decided - # there was nothing to do (morph#179). See scripts/ladder_rungs.sh - # for what the non-rung half of the pattern covers and why. + # here. A hand-copied rung alternation drifts behind the file it + # copies, and the drift is silent: a change confined to a rung the + # pattern has not caught up with matches nothing and skips this job, + # and a filter that matches nothing looks exactly like one that + # correctly decided there was nothing to do. See + # scripts/ladder_rungs.sh for what the non-rung half of the pattern + # covers and why. pattern="$(bash scripts/ladder_rungs.sh ci-path-regex)" echo "ladder path filter: $pattern" if echo "$changed" | grep -qE "$pattern"; then @@ -1517,13 +1494,13 @@ jobs: # case (find_server), which is correct and deliberately not softened # into a skip -- but it only says so after a full Qt + ladder build. # - # Both are morph#462's own failure one level down: a gate that quietly + # Both are the same failure one level down: a gate that quietly # runs five corpora out of six reports green exactly as loudly as one # that ran them all. So they are checked here instead, on a bare # checkout, in seconds, before anything is installed or compiled. # # A corpus can also arrive for something that is not a ladder rung at - # all. examples/bank is the live case (morph#87/#470): its server comes + # all. examples/bank is the live case: its server comes # from a local add_executable() rather than morph_add_rung(), and bank is # deliberately absent from examples/rungs.txt. So the check has a third # arm for a corpus whose server is declared that way, and this job @@ -1565,8 +1542,8 @@ jobs: # The third way a corpus can have a server this job builds: a plain # add_executable(ladder__server) in examples//CMakeLists.txt, - # with no line in examples/rungs.txt at all. bank is that case - # (morph#87/#470) -- it predates the ladder, carries no rung number, + # with no line in examples/rungs.txt at all. bank is that case -- + # it predates the ladder, carries no rung number, # and listing it in rungs.txt would enrol it in every consumer that # derives from that file rather than describe it, which rungs.txt's # own comment settles at length. So the `configured` arm below cannot @@ -1687,8 +1664,7 @@ jobs: run: | # `--fail` and a file rather than a pipe into tar: see # linux-compilers' identical Install sccache step for why an - # unchecked download reported itself as "not in gzip format" - # (morph#672). + # unchecked download reports itself as "not in gzip format". curl -sSL --fail -o /tmp/sccache.tar.gz \ https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ @@ -1745,25 +1721,25 @@ jobs: # configure. cmake --build --preset gcc-debug --target ladder_bank_server - # ── moc output that climbs out of the build tree (issue #372) ──── + # ── moc output that climbs out of the build tree ───────────────── # Same gate as linux-qt above, and for the same reason -- see its # comment. This job is the one that matters most: ladder__gui_lib - # is where #372 actually bit, and it is the AUTOMOC target every - # ladder__tests binary links, so this build tree is the one - # carrying the output that stopped compiling. + # is the AUTOMOC target every ladder__tests binary links, so this + # build tree is the one carrying the output that stops compiling when a + # generated include ascends. - name: Check no generated moc include ascends if: steps.filter.outputs.run == 'true' run: bash scripts/check_automoc_includes.sh build/gcc-debug - # ── Two ctest entries under one name (morph#464) ────────────────── + # ── Two ctest entries under one name ────────────────────────────── # A ctest name is global to the build tree, not scoped to the target it # was discovered from, and rung test binaries share TEST_CASE names # freely. Every by-name operation then addresses both entries at once: # `set_tests_properties( PROPERTIES LABELS ...)` reaches both and - # CTest *appends*, so one entry accumulated the other rung's label and - # `ctest -L ladder-` ran another rung's binary alongside its own - # (crm reported on 180 cases while owning 168); a failure line named a - # test that existed twice. cmake/morph_add_rung.cmake's TEST_PREFIX makes + # CTest *appends*, so one entry accumulates the other rung's label and + # `ctest -L ladder-` runs another rung's binary alongside its own + # (measured once as crm reporting on 180 cases while owning 168); a + # failure line then names a test that exists twice. cmake/morph_add_rung.cmake's TEST_PREFIX makes # those names unique by construction -- this is what keeps them that way # the next time a rung is template-copied or a suite is registered by # hand. @@ -1785,13 +1761,13 @@ jobs: run: ctest --preset gcc-debug -L ladder -LE stress --output-on-failure # ── The scenario corpus, against the servers just built ──────────── - # Nothing in CI ran scripts/scenario/run_scenarios.py until this step - # (morph#462). The corpus could therefore be refused wholesale by a real - # server with every workflow still green -- and was: sixteen of sixteen - # ledger scenarios refused for five days and through a merge (morph#460), - # while drift-guard.yml's scenario-coverage job reported "ledger actions - # 18/18 dispatched, workflows 16/16" throughout. That job parses the - # corpus; only running it can see a refusal. + # This step is the only place in CI that runs + # scripts/scenario/run_scenarios.py. Without it the corpus can be refused + # wholesale by a real server with every workflow still green: measured + # once as sixteen of sixteen ledger scenarios refused for five days and + # through a merge, while drift-guard.yml's scenario-coverage job reported + # "ledger actions 18/18 dispatched, workflows 16/16" throughout. That job + # parses the corpus; only running it can see a refusal. # # Here rather than in drift-guard.yml, for the reason drift-guard.yml's # own comment gives for deferring it: running the corpus needs the @@ -1819,8 +1795,8 @@ jobs: # a full 26-74 minute cycle to discover it. Not `always()`, which also # fires during teardown of a *cancelled* run and manufactures a leg that # stopped within seconds of its siblings looking like a defect rather - # than a cancellation. The job's conclusion is unchanged either way: a - # red step keeps the job red (morph#618). + # than a cancellation. The job's conclusion is the same either way: a + # red step keeps the job red. - name: Run the scenario corpus against the built servers if: "!cancelled() && steps.filter.outputs.run == 'true'" env: @@ -1888,10 +1864,11 @@ jobs: # per-commit entry, and with 16 cache-writing legs at 1 GB each one # generation already exceeds GitHub's 10 GB per-repo budget -- so # legs evicted each other and entries rarely survived to be reused. - # Restricting writes to master keeps morph#109's property (only one - # branch ever writes, so no branch can delete another's entry) while - # collapsing the generations that actually blow the budget. Pull - # requests still restore, read-only, and get the full benefit. + # Restricting writes to master keeps the property that makes the cache + # usable at all (only one branch ever writes, so no branch can delete + # another's entry) while collapsing the generations that actually blow + # the budget. Pull requests still restore, read-only, and get the full + # benefit. - name: Save sccache if: "steps.filter.outputs.run == 'true' && github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') && !contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted')" uses: actions/cache/save@v4 @@ -1914,9 +1891,9 @@ jobs: # every path, and against an uninstrumented system Qt that produces warnings # bottoming out in Qt-internal frames which cannot be classified as real # races or false positives from outside a TSan-instrumented Qt build — - # morph#128 hit exactly that, 165 warnings deep. The resolution there was to - # rewrite the one test that mattered to construct no QtExecutor at all and - # run only it under TSan, which is what kanban-tsan above does. Thread- + # measured at 165 warnings deep. The way through is to write the one test + # that matters so it constructs no QtExecutor at all and run only it under + # TSan, which is what kanban-tsan above does. Thread- # sanitising a rung means following that pattern per test, not adding a # blanket -DAF_SANITIZER=tsan leg here. # @@ -1944,17 +1921,17 @@ jobs: # identical tree and differ only in instrumentation, so a change that # warrants running one warrants running the other, and the two patterns # must not be able to diverge. This job is the only one in the repository - # that sanitizer-instruments a rung, which is what the drifted filter - # actually cost (morph#179). + # that sanitizer-instruments a rung, so a filter that drifts behind + # examples/rungs.txt silently stops sanitising whatever it has not caught + # up with. - name: Determine whether the ladder needs to run id: filter run: | # Not because `echo | grep` can lose a status today -- `echo` does # not fail -- but because this block decides whether a whole job # runs, and the next edit to it is the one that adds a pipeline - # that can (morph#730). The checker that enforced the same rule on - # every other `run:` block was removed with the meta-gates, so this - # is a convention now rather than something CI holds you to. + # that can. Nothing enforces this rule across the file's other `run:` + # blocks, so it is a convention rather than something CI holds you to. set -o pipefail if [ "${{ github.event_name }}" = "pull_request" ]; then base="${{ github.event.pull_request.base.sha }}" @@ -1995,7 +1972,7 @@ jobs: unixodbc-dev libsqliteodbc libyaml-cpp-dev libzip-dev libgl1-mesa-dev # --fail, a file, and a non-empty check -- never `wget -qO- | sudo # bash`: see linux-compilers' identical install step for why the piped - # form reported success having installed nothing (morph#681). + # form reports success having installed nothing. curl -sSL --fail -o /tmp/llvm.sh https://apt.llvm.org/llvm.sh test -s /tmp/llvm.sh sudo bash /tmp/llvm.sh ${{ env.CLANG_VERSION }} @@ -2031,8 +2008,7 @@ jobs: run: | # `--fail` and a file rather than a pipe into tar: see # linux-compilers' identical Install sccache step for why an - # unchecked download reported itself as "not in gzip format" - # (morph#672). + # unchecked download reports itself as "not in gzip format". curl -sSL --fail -o /tmp/sccache.tar.gz \ https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ @@ -2070,17 +2046,18 @@ jobs: # -DAF_SANITIZER=asan instrumented morph_tests and nothing under # examples/. # - # This used to be a hand-rolled `nm | grep __asan_` over - # `examples/*/ladder_*_tests` alone, which is the same shape of blind - # spot one level up: it asserted the instrumentation of the binaries + # Not a hand-rolled `nm | grep __asan_` over + # `examples/*/ladder_*_tests`, which is the same shape of blind + # spot one level up: it asserts the instrumentation of the binaries # someone thought to name, and this job also builds tests/qt, - # examples/qt_tls_client and examples/concepts, none of which the glob - # reaches (morph#542). The shared script walks what ctest will actually + # examples/qt_tls_client and examples/concepts, none of which that glob + # reaches. The shared script walks what ctest will actually # run instead of a pattern, so a suite added tomorrow is covered by # having been added. # # QT_QPA_PLATFORM=offscreen, matching the Build step above and the Test - # step below, for the reason morph#691 gives: this sweep opens with `ctest + # step below, for the reason the identical step elsewhere gives: this + # sweep opens with `ctest # --show-only=json-v1`, and listing a DISCOVERY_MODE PRE_TEST suite *runs* # its binary -- so a Qt-linked PRE_TEST suite in this configure would # abort headless and take the entire listing down, every other suite @@ -2088,8 +2065,8 @@ jobs: # (cmake/morph_add_rung.cmake, examples/common/CMakeLists.txt, # tests/qt/CMakeLists.txt), so discovery already happened under the Build # step's copy of this variable. That is an accident of one keyword, not a - # property of this step, and it is the accident bank-sanitizers did not - # have (morph#690). + # property of this step: flip any of those suites to PRE_TEST and the + # variable becomes load-bearing here too. - name: Every ctest binary is instrumented if: steps.filter.outputs.run == 'true' env: @@ -2109,9 +2086,9 @@ jobs: UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 run: ctest --preset clang-asan -L ladder -LE stress --output-on-failure - # The Qt transport suites, which no sanitizer leg ran. They were not even - # instrumented until morph#542, and instrumenting a suite nothing runs - # would swap one vacuous control for another: this is the only leg that + # The Qt transport suites, which no other sanitizer leg runs. + # Instrumenting a suite nothing runs swaps one vacuous control for + # another, so both halves belong here: this is the only leg that # builds Qt under a sanitizer, so if these run anywhere it is here. # linux-sanitizers deliberately keeps Qt out of its matrix (see its # Configure comment), and kanban-tsan runs exactly one named stress test. @@ -2124,9 +2101,8 @@ jobs: # # `--no-tests=error` because `-R` over test names is exactly the filter # that goes stale silently: a renamed TEST_CASE would otherwise leave - # ctest reporting success having run nothing (morph#466 is the same trap - # one level down). - # `!cancelled()`, for the reason morph#618 gives and which bites hardest + # ctest reporting success having run nothing. + # `!cancelled()`, for the reason the other test steps give, which bites hardest # here: this is the only leg in CI that runs the Qt transport suites # under any sanitizer, so if the ladder step above goes red and this one # is skipped, that surface is measured nowhere at all. Not `always()` -- @@ -2197,10 +2173,11 @@ jobs: # per-commit entry, and with 16 cache-writing legs at 1 GB each one # generation already exceeds GitHub's 10 GB per-repo budget -- so # legs evicted each other and entries rarely survived to be reused. - # Restricting writes to master keeps morph#109's property (only one - # branch ever writes, so no branch can delete another's entry) while - # collapsing the generations that actually blow the budget. Pull - # requests still restore, read-only, and get the full benefit. + # Restricting writes to master keeps the property that makes the cache + # usable at all (only one branch ever writes, so no branch can delete + # another's entry) while collapsing the generations that actually blow + # the budget. Pull requests still restore, read-only, and get the full + # benefit. - name: Save sccache if: "steps.filter.outputs.run == 'true' && github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') && !contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted')" uses: actions/cache/save@v4 @@ -2280,7 +2257,7 @@ jobs: else # --fail, a file, and a non-empty check -- never `wget -qO- | sudo # bash`: see linux-compilers' identical install step for why the piped - # form reported success having installed nothing (morph#681). + # form reports success having installed nothing. curl -sSL --fail -o /tmp/llvm.sh https://apt.llvm.org/llvm.sh test -s /tmp/llvm.sh sudo bash /tmp/llvm.sh ${{ env.CLANG_VERSION }} @@ -2318,8 +2295,7 @@ jobs: run: | # `--fail` and a file rather than a pipe into tar: see # linux-compilers' identical Install sccache step for why an - # unchecked download reported itself as "not in gzip format" - # (morph#672). + # unchecked download reports itself as "not in gzip format". curl -sSL --fail -o /tmp/sccache.tar.gz \ https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ @@ -2351,8 +2327,8 @@ jobs: # bank example's own subdirectory) was, until this line, the single # MORPH_BUILD_* option no job in .github/workflows/ enabled at all: # the `linux-everything` preset that sets it is named by no workflow. - # morph#604 is what that costs -- bank_gui stopped compiling on - # master with every gate green. This is the one leg that can host it: + # What that costs is a target that stops compiling on master with + # every gate green. This is the one leg that can host it: # it already installs Qt ${{ env.QT_VERSION }} from aqtinstall (the # GUI needs Qml/Quick/QuickControls2 at 6.5+, which the distro Qt the # other bank jobs use cannot give) and already installs the @@ -2394,7 +2370,7 @@ jobs: QT_QPA_PLATFORM: offscreen run: cmake --build --preset ${{ matrix.preset }} - # ── moc output that climbs out of the build tree (issue #372) ──── + # ── moc output that climbs out of the build tree ───────────────── # Same gate as linux-qt, and for the same reason -- see its comment. # This is the widest build it reaches: the clang leg is the only one that # compiles moc output under -Weverything, where -Wshadow-header is what @@ -2408,18 +2384,17 @@ jobs: # deterministic regression check, not a fuzzing campaign. # # ── --parallel: the one leg in this repository that runs ctest in - # parallel, deliberately (morph#685) ───────────────────────────────── + # parallel, deliberately ───────────────────────────────────────────── # - # Every other ctest invocation in .github/workflows/ is serial, and until - # this line all sixteen of them were. That made a whole class of defect - # invisible to CI by construction: two ctest cases sharing an on-disk - # database, a port, or a temp path cannot collide when only one runs at a - # time. morph#682 is the measured instance -- bank's suites were 0/21 at - # `ctest -j 12` on a workstation and 21/21 serially, and CI was green - # throughout, on every leg, for as long as the defect existed. + # Every other ctest invocation in .github/workflows/ is serial, which + # makes a whole class of defect invisible to CI by construction: two + # ctest cases sharing an on-disk database, a port, or a temp path cannot + # collide when only one runs at a time. Measured instance: bank's suites + # at 0/21 under `ctest -j 12` on a workstation and 21/21 serially, with + # CI green throughout, on every leg, for as long as the defect existed. # # This leg and not another: it is the only one whose ctest covers the - # bank suites morph#682 was about *and* every ladder rung *and* Qt *and* + # bank suites *and* every ladder rung *and* Qt *and* # the core suites, so it is the only place where one flag exposes the # whole class rather than one corner of it. It also costs no new leg -- # a second job would add its own configure and build to every future PR, @@ -2437,22 +2412,22 @@ jobs: # ctest --parallel 4 1898/1898 passed, 109.38 s (hosted vCPU count) # # So for the part that could be measured locally this is a saving, not a - # cost: the test step gets faster, and the leg's wall time with it. - # morph#685 separately measured bank at 21/21 in 1.1 s parallel against - # 5.7 s serial after morph#686's per-case databases landed. + # cost: the test step gets faster, and the leg's wall time with it. Bank, + # measured separately once its suites took per-case databases, is 21/21 + # in 1.1 s parallel against 5.7 s serial. # # NOT measured, and this is the risk: the ladder rungs in parallel with # each other. They declare RESOURCE_LOCK (cmake/morph_add_rung.cmake), # which is what ctest --parallel consults to serialise the cases that - # need it -- a mechanism that exists precisely for this and that no leg - # has ever exercised. morph#658 (`SQLite database is locked`, ledger - # corpus) is the reason to expect a first bill here. + # need it -- a mechanism that exists precisely for this and that no other + # leg exercises. A `SQLite database is locked` in the ledger corpus is + # the shape of first bill to expect here. # # If this leg goes red or flaky on contention rather than on a real # regression, the failing case is the finding -- file it, fix the # isolation, and keep the flag. Dropping `--parallel` again puts the - # whole class back out of CI's sight, which is the state morph#685 exists - # to end. $(nproc) rather than a literal: hosted runners give 4, the + # whole class back out of CI's sight, which is what this flag exists to + # prevent. $(nproc) rather than a literal: hosted runners give 4, the # self-hosted fleet gives more, and the point is contention, not a number. - name: Test (offscreen Qt platform, ctest in parallel) env: @@ -2462,7 +2437,7 @@ jobs: # A guard that never fires is indistinguishable from one that works, so # assert the fuzz replay actually ran the committed crash reproducers # rather than silently matching nothing (the `.txt`-vs-`.bin` glob bug). - # `!cancelled()`, per morph#618: this checks something the ctest step + # `!cancelled()`: this checks something the ctest step # above cannot report on -- whether the replay matched the committed # reproducers at all, the `.txt`-vs-`.bin` glob bug -- so skipping it # when some unrelated test fails hides a guard going vacuous behind an @@ -2534,10 +2509,11 @@ jobs: # per-commit entry, and with 16 cache-writing legs at 1 GB each one # generation already exceeds GitHub's 10 GB per-repo budget -- so # legs evicted each other and entries rarely survived to be reused. - # Restricting writes to master keeps morph#109's property (only one - # branch ever writes, so no branch can delete another's entry) while - # collapsing the generations that actually blow the budget. Pull - # requests still restore, read-only, and get the full benefit. + # Restricting writes to master keeps the property that makes the cache + # usable at all (only one branch ever writes, so no branch can delete + # another's entry) while collapsing the generations that actually blow + # the budget. Pull requests still restore, read-only, and get the full + # benefit. - name: Save sccache if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') && !contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted') uses: actions/cache/save@v4 @@ -2583,8 +2559,7 @@ jobs: run: | # `--fail` and a file rather than a pipe into tar: see # linux-compilers' identical Install sccache step for why an - # unchecked download reported itself as "not in gzip format" - # (morph#672). + # unchecked download reports itself as "not in gzip format". curl -sSL --fail -o /tmp/sccache.tar.gz \ https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ @@ -2646,14 +2621,14 @@ jobs: if: always() run: sccache --show-stats || true - # Save only on a push to master. Every run used to write a new - # per-commit entry, and with 16 cache-writing legs at 1 GB each one - # generation already exceeds GitHub's 10 GB per-repo budget -- so - # legs evicted each other and entries rarely survived to be reused. - # Restricting writes to master keeps morph#109's property (only one - # branch ever writes, so no branch can delete another's entry) while - # collapsing the generations that actually blow the budget. Pull - # requests still restore, read-only, and get the full benefit. + # Save only on a push to master. Writing a new per-commit entry from + # every run does not work here: with 16 cache-writing legs at 1 GB each, + # one generation exceeds GitHub's 10 GB per-repo budget, so legs evict + # each other and entries rarely survive to be reused. Restricting writes + # to master keeps the property that makes the cache usable at all (only + # one branch ever writes, so no branch can delete another's entry) while + # collapsing the generations that blow the budget. Pull requests still + # restore, read-only, and get the full benefit. - name: Save sccache if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') uses: actions/cache/save@v4 @@ -2663,8 +2638,8 @@ jobs: # ── clang-format (whole tree) ───────────────────────────────────────── # - # .clang-format has always been in the tree, and nothing enforced it: 471 of - # 677 tracked .hpp/.cpp files did not match it (morph#210). An unenforced + # .clang-format is in the tree, and an unenforced one drifts fast: measured + # once at 471 of 677 tracked .hpp/.cpp files not matching it. An unenforced # config is worse than none, because every editor with format-on-save obeys it # faithfully -- so touching one file rewrites parts the author never meant to # change, and the reviewer's job becomes separating intent from churn. @@ -2688,7 +2663,7 @@ jobs: run: | # --fail, a file, and a non-empty check -- never `wget -qO- | sudo # bash`: see linux-compilers' identical install step for why the piped - # form reported success having installed nothing (morph#681). + # form reports success having installed nothing. curl -sSL --fail -o /tmp/llvm.sh https://apt.llvm.org/llvm.sh test -s /tmp/llvm.sh sudo bash /tmp/llvm.sh ${{ env.CLANG_VERSION }} @@ -2698,7 +2673,7 @@ jobs: run: | # Without pipefail the count below is `wc`'s status, not `tr`'s: a # missing or unreadable cpp-files.z yields COUNT=0 rather than an - # error (morph#730). The `-lt 100` guard catches that particular + # error. The `-lt 100` guard catches that particular # case; pipefail is what keeps the *next* pipeline here honest. set -o pipefail git ls-files -z '*.hpp' '*.cpp' > cpp-files.z @@ -2765,7 +2740,7 @@ jobs: libxcb-keysyms1 libxcb-shape0 libxcb-xinerama0 # --fail, a file, and a non-empty check -- never `wget -qO- | sudo # bash`: see linux-compilers' identical install step for why the piped - # form reported success having installed nothing (morph#681). + # form reports success having installed nothing. curl -sSL --fail -o /tmp/llvm.sh https://apt.llvm.org/llvm.sh test -s /tmp/llvm.sh sudo bash /tmp/llvm.sh ${{ env.CLANG_VERSION }} @@ -2790,7 +2765,7 @@ jobs: # This step reads the version out of the headers the step above just # installed and fails if it is not the one CATCH2_VERSION records, so a # move in the runner image is a red job rather than a quiet change of - # what this gate measures (morph#666). Its self-test runs first, for the + # what this gate measures. Its self-test runs first, for the # same reason the suppression-scope checker's does. - name: Self-test the catch2-pin checker run: bash scripts/test_check_catch2_pin.sh @@ -2798,13 +2773,13 @@ jobs: - name: Assert the Catch2 this job analyses against run: bash scripts/check_catch2_pin.sh . --strict - # Catches morph#632's bug class: tests/.clang-tidy's thirteen - # suppressions are argued as Catch2 and raw-syscall idiom, which is true - # of test sources and says nothing about include/morph/** -- yet + # Catches a bug class this file's own layout invites: tests/.clang-tidy's + # thirteen suppressions are argued as Catch2 and raw-syscall idiom, which + # is true of test sources and says nothing about include/morph/** -- yet # clang-tidy resolves configuration from the translation unit's path, so - # they are off for every header a tests/ TU reaches as well. Measured on - # a8511aa6: 333 findings inside include/morph/**, across 25 headers, - # reported from those 134 TUs only once that file is removed. The reach + # they are off for every header a tests/ TU reaches as well. Measured: + # 333 findings inside include/morph/**, across 25 headers, reported from + # those 134 TUs once that file is removed. The reach # cannot be narrowed, so it is written down in tests/.clang-tidy -- and # this step is what keeps the record from going stale. - name: Check tests/.clang-tidy records the reach it actually has @@ -2826,10 +2801,10 @@ jobs: # the one gate in this workflow that reads code rather than running it. # # MORPH_BUILD_LADDER + MORPH_BUILD_BANK_EXAMPLE are the two that make that - # claim true rather than aspirational (morph#481). Both default OFF and + # claim true rather than aspirational. Both default OFF and # gate `add_subdirectory(examples)` / `add_subdirectory(examples/bank)`, - # so until they were passed here the database held 261 entries of which - # 16 were under examples/ — four directories, none of them a ladder rung. + # so without them the database holds 261 entries of which + # 16 are under examples/ — four directories, none of them a ladder rung. # examples/ is 69% of the repository's C/C++ files (524 of 793 were # outside the database, against 60 under include/morph), and clang-tidy # does not skip a file it has no compile command for: clang tooling @@ -2862,25 +2837,25 @@ jobs: # output, while reading as though it had settled the question. It has # not: AUTOMOC's output is the other kind -- a build-time # add_custom_command -- and two sources #include it by name. See the - # AUTOMOC step below (morph#624), which is why this now says "no full - # Build step" rather than "no Build step". + # AUTOMOC step below, which is why this says "no full Build step" rather + # than "no Build step". # # Compiler caching (sccache/fastcache-cc) is still not set up here. That # step compiles 67 objects; a real build of this configure would compile # the database's 737, so the caching steps would cost more setup than # they could save. # - # MORPH_BUILD_BANK_GUI is the last of the two flags "every optional - # feature is ON here" was not actually true about (morph#651). It + # MORPH_BUILD_BANK_GUI is the second flag "every optional feature is ON + # here" is easy to be wrong about. It # defaults OFF (CMakeLists.txt), gates `add_subdirectory(gui)` in # examples/bank/CMakeLists.txt, and the bank GUI's *tests* are gated on # it too -- bank_gui_tests only exists `if(TARGET bank_gui_lib)`, which - # is that option's target. Eleven tracked sources therefore had no - # compile command in this job's database and were dropped, unanalysed, + # is that option's target. Without it, eleven tracked sources have no + # compile command in this job's database and are dropped, unanalysed, # by the filter below: nine under examples/bank/gui/ and two under # examples/bank/tests/gui/. # - # Measured locally on 4563aff3, clang 22.1.8, Qt 6, this job's own flag + # Measured locally with clang 22.1.8, Qt 6, this job's own flag # set, cold build directory each time: # # without BANK_GUI: 703 entries, 695 distinct in-workspace sources, @@ -2897,8 +2872,8 @@ jobs: # This needs nothing new installed: linux-all-features already builds # this option on the same runner image with the same # `jurplel/install-qt-action@v4 modules: qtwebsockets` install and the - # same apt set (morph#604 is why it is there), so the GUI's - # Qml/Quick/QuickControls2 requirement is already known to be met. + # same apt set, so the GUI's Qml/Quick/QuickControls2 requirement is + # already known to be met. # # What it does *not* do is clear the eleven files' existing findings. # Measured on the same configure, whole-file, with this step's own @@ -2908,8 +2883,8 @@ jobs: # readability-identifier-length, 13 others), across ten of them -- # BankController.cpp is clean. clang-tidy-diff only reports on *changed* # lines, so none of that goes red until someone edits one of these files - # on a line that carries a finding. That is morph#656, filed rather than - # folded in here; morph#646 is the precedent for keeping the two apart. + # on a line that carries a finding. Clearing them is separate work from + # putting them in the database, and is deliberately not folded in here. - name: Configure (generates compile_commands.json over every optional feature) run: | cmake --preset clang-debug \ @@ -2934,10 +2909,10 @@ jobs: # does not exist and clang-tidy dies on the whole translation unit -- # `error: 'tst_main.moc' file not found [clang-diagnostic-error]` -- with # WarningsAsErrors:"*" turning it into a failed job, before a single - # changed line is analysed (morph#624). Reproduced locally on c55ea5b7 - # with this job's own configure flags and clang-tidy 22.1.8: one line - # added to each of the two files, and clang-tidy-diff.py exits 1 with - # exactly those two diagnostics and nothing else. + # changed line is analysed. Reproduced locally with this job's own + # configure flags and clang-tidy 22.1.8: one line added to each of the + # two files, and clang-tidy-diff.py exits 1 with exactly those two + # diagnostics and nothing else. # # Suppressing the diagnostic instead is not available, not merely # unattractive. -Wno-missing-include-dirs below covers an include @@ -2948,8 +2923,7 @@ jobs: # `-checks=-clang-diagnostic-*` and `--warnings-as-errors=''` each still # exit 1 reporting the same line. The only remaining way to "filter" it # would be to grep clang-tidy-diff.py's output and override its exit - # code, which is morph#479's defect -- a gate that cannot fail -- rebuilt - # deliberately. + # code -- a gate that cannot fail, built deliberately. # # So the headers get generated, for these two targets only. Not `cmake # --build` over everything, and not every *_autogen target either: @@ -3007,7 +2981,7 @@ jobs: if command is None: # Not in this configure's database: clang-tidy cannot # analyse the file at all, which is a different problem - # (morph#481) and not one this step can fix. + # and not one this step can fix. print(f"::warning::{name} self-includes {moc} but has no " f"compile command in this configure") continue @@ -3060,7 +3034,7 @@ jobs: # `-o pipefail` -- so a pipeline exits with its *last* command's # status. Ending in `tee`, which always succeeds, discarded # clang-tidy-diff.py's exit code and left this gate unable to fail on - # anything it found (morph#479). `tee` stays: the Upload step below + # anything it found. `tee` stays: the Upload step below # publishes the file it writes. Set here and not at the top, and not # via `shell: bash` (which is `bash --noprofile --norc -eo pipefail # {0}`), because the `find ... | head -1` above would then fail the @@ -3069,26 +3043,26 @@ jobs: set -o pipefail # No -regex narrowing here, deliberately: every changed C/C++ file in - # the diff is analysed. The `-regex` that used to exclude - # examples/{bank,common,} existed only because the - # Configure step left MORPH_BUILD_LADDER and MORPH_BUILD_BANK_EXAMPLE - # at their OFF defaults, so those files had no compile command and - # could only ever produce `'ledger/core/errors.hpp' file not found`. - # The Configure step now passes both, so they do (morph#481) -- - # deleting that regex is what turns the ladder configure from pure - # cost into coverage. Restoring any exclusion here silently undoes 276 - # of the database's 690 entries. + # the diff is analysed. A `-regex` excluding + # examples/{bank,common,} is only defensible while the + # Configure step leaves MORPH_BUILD_LADDER and MORPH_BUILD_BANK_EXAMPLE + # at their OFF defaults, since those files then have no compile command + # and can only produce `'ledger/core/errors.hpp' file not found`. + # The Configure step passes both, which is what turns the ladder + # configure from pure cost into coverage. Restoring any exclusion here + # silently undoes 276 of the database's 690 entries. # # Deliberately NOT clang-tidy-diff.py's own -only-check-in-db either, # which filters on literal membership of compile_commands.json: no # header is ever a compilation unit, so it would silently drop all of - # include/morph/** -- morph#479's own defect one directory over. + # include/morph/** -- the same gate-measures-nothing failure one + # directory over. # - # -extra-arg is appended to the *compiler* command line, so the - # -extra-arg=-warnings-as-errors=* that used to sit here reached clang - # as an unknown argument and made every translation unit fail with - # `error: unknown argument` -- i.e. clang-tidy-diff.py exited 1 on - # every non-empty diff, clean or not. Dropped rather than corrected: + # -extra-arg is appended to the *compiler* command line, so an + # -extra-arg=-warnings-as-errors=* here reaches clang as an unknown + # argument and makes every translation unit fail with + # `error: unknown argument` -- i.e. clang-tidy-diff.py exits 1 on + # every non-empty diff, clean or not. There is nothing to add: # .clang-tidy already carries `WarningsAsErrors: "*"`. # # -Wno-missing-include-dirs: this job configures but never builds (see @@ -3110,55 +3084,52 @@ jobs: # *by name*. This flag says nothing about `#include "tst_main.moc"`: # the directory it would be found in is one of the 90, but the error # raised is the file not being there, not the directory. That is what - # the AUTOMOC step above generates, and why this job now builds two - # targets (morph#624). + # the AUTOMOC step above generates, and why this job builds two + # targets. # And it is not sufficient for a source this configure does not - # build at all. That is the third instance of one structural - # problem, after morph#624 (a generated header) and morph#650 - # (tests/lint/ text fixtures): clang-tidy-diff.py analyses every + # build at all. That is one structural problem with three faces -- + # a generated header, tests/lint/ text fixtures, and an + # unbuilt source: clang-tidy-diff.py analyses every # changed C/C++ line, a changed *comment* line is a changed line, # and clang tooling does not skip a file it has no compile command # for -- it interpolates a neighbouring entry's command. What comes # back is a clang-diagnostic-error about this job's configure, not a # lint finding about the diff, and WarningsAsErrors:"*" makes it a - # failed job. Measured on morph#649's branch, which edits five such + # failed job. Measured on a branch editing five such # sources in comments only: 21 clang-diagnostic-errors from the four # WASM mains (no Emscripten/Qt-WASM toolchain here) and from # tests/compile_checks/client_only_facade_no_model_header.cpp (built # by a configure-time try_run() with -DMORPH_CLIENT_ONLY, and # deliberately incomplete without it). # - # Reverting the comment edits is not a general answer and in that - # case was not even a local one: those four comments cite a - # docs/spec/core/backend.md section the same branch renames, so - # leaving them alone turns the spec-citation gate red instead - # (reproduced -- four "dangling section citation" errors). + # Not editing the comments is no answer either: a comment that cites + # a docs/spec section a branch renames has to change with it, or the + # spec-citation gate goes red instead. # # So the diff is filtered first: a changed *source* with no entry in # compile_commands.json is dropped, and every drop is printed. A # changed *header* cannot be dropped on that test -- a header is # never a translation unit, so -only-check-in-db's literal # membership test would silently discard all of include/morph/**, - # which is morph#479's own defect one directory over (see above). + # which is the same gate-measures-nothing failure (see above). # The filter refuses to run at all unless the database is still the # wide one the Configure step builds, because a filter consulting a # narrowed database would quietly stop analysing everything the # narrowing dropped. # - # A changed header used to be kept and handed to clang-tidy with no - # command at all, which is not the same as being analysed: clang + # Keeping a changed header and handing it to clang-tidy with no + # command at all is not the same as analysing it: clang # falls back to InterpolatingCompilationDatabase, borrows the # nearest entry's flags, and reports whatever the resulting broken - # parse produces as findings *in the contributor's file*. morph#753 - # reproduced it on a new header in a directory with no .cpp: + # parse produces as findings *in the contributor's file*. Reproduced + # on a new header in a directory with no .cpp: # 'Lightweight/SqlConnection.hpp' file not found, plus a # cppcoreguidelines-pro-type-member-init and a # readability-identifier-naming that are both false -- the # constructor does initialise those fields, and `_previous` is - # spelled like every other private member here. Three lanes hit the - # same fallback in one week from three directions. + # spelled like every other private member there. # - # So a changed header is now *pinned*: the filter takes candidate + # So a changed header is *pinned*: the filter takes candidate # commands from the database nearest-first, actually runs each one # (`-fsyntax-only -x c++` on the header, with this job's own # -extra-args), and writes the first that parses into an augmented @@ -3185,7 +3156,7 @@ jobs: # Floors, not equalities. This filter is only trustworthy while the # database it consults is the wide one the Configure step builds: if # that configure regresses -- MORPH_BUILD_LADDER back to its OFF - # default is the concrete way, morph#481 -- every examples/ source + # default is the concrete way -- every examples/ source # would silently become "not built by this configure" and stop being # analysed, and this gate would go green having read 40% less code. MIN_ENTRIES = 600 @@ -3358,10 +3329,10 @@ jobs: if not sections: # A diff with no file sections at all means clang-tidy-diff.py # would be handed nothing, analyse nothing and exit 0 -- a green - # gate over an empty input, which is exactly morph#776's shape - # one level up (there it was `git diff -U0 HEAD` on a committed - # branch; here it would be a BASE_SHA that does not name this - # branch's fork point). Neither a pull_request nor a push event + # gate over an empty input. The same shape one level up is a + # local `git diff -U0 HEAD` on a committed branch; here it would + # be a BASE_SHA that does not name this branch's fork point. + # Neither a pull_request nor a push event # can legitimately produce an empty diff, so this is loud rather # than silent. print( @@ -3387,7 +3358,7 @@ jobs: # The augmented database: every original entry, plus one pinned entry per # changed header. clang-tidy is pointed at this one, so it never reaches # InterpolatingCompilationDatabase for a header and never analyses one with a - # command nobody checked (morph#753). + # command nobody checked. out_db = pathlib.Path(sys.argv[4]) out_db.mkdir(parents=True, exist_ok=True) (out_db / "compile_commands.json").write_text(json.dumps(database)) @@ -3404,7 +3375,7 @@ jobs: f"::warning file={name}::not analysed by clang-tidy-diff: none of the " f"{MAX_HEADER_CANDIDATES} nearest compile commands in this configure can " f"parse this header, so clang-tidy would report the failed parse as " - f"findings in your code (morph#753). Build this header from some " + f"findings in your code. Build this header from some " f"translation unit to have it analysed." ) for name, donor in pinned: @@ -3430,7 +3401,8 @@ jobs: # -path /tmp/tidy-db, not build/clang-debug: that is the augmented # database, and pointing clang-tidy at it is the whole fix for - # morph#753. The floors above are still evaluated against + # the interpolated-command problem. The floors above are still + # evaluated against # build/clang-debug's own database, so narrowing cannot hide behind # the augmentation. if ! python3 "$CLANG_TIDY_DIFF" \ diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 46785dc2a..dbf2a3fc0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -50,7 +50,7 @@ jobs: - name: Install Doxygen 1.17.0 run: | # `--fail` and `-S`, for the reason ci.yml's Install sccache step - # gives at length (morph#672): without them, an HTTP 503 from the + # gives at length: without them, an HTTP 503 from the # download host is a *successful* transfer of an error page, curl # exits 0 having written it to the file, and the only diagnostic # anyone sees is `gzip: stdin: not in gzip format` from tar -- which diff --git a/.github/workflows/wasm-ladder.yml b/.github/workflows/wasm-ladder.yml index 94840c6a7..e8f799021 100644 --- a/.github/workflows/wasm-ladder.yml +++ b/.github/workflows/wasm-ladder.yml @@ -17,11 +17,11 @@ name: WASM ladder gate # rung list still has to be written out by hand. GitHub evaluates `on.*.paths` # to decide whether to start the workflow at all, which happens before any step # of it can run -- so nothing here can read examples/rungs.txt, the list every -# other consumer derives from. That is exactly how they fell a rung behind: -# both stopped at kanban, so ledger and lims matched nothing (morph#179). +# other consumer derives from. That is exactly how such a list falls a rung +# behind: it stops at whatever the last edit knew about, and every rung added +# after that matches nothing here. # -# They were checked against examples/rungs.txt from the outside by a -# drift-guard job, removed with the meta-gates. Adding a rung still means +# Nothing checks these two lists against examples/rungs.txt. Adding a rung means # adding two lines here -- but nothing catches forgetting any more, so the # two lists have to be kept in step by hand. on: @@ -151,9 +151,10 @@ jobs: # missing, announcing why) fails this job instead of passing it # vacuously. The plain build that follows covers everything else. # - # The rungs are read from examples/rungs.txt rather than listed here -- - # this loop used to name `pastebin bookmarks polls` literally, a third - # hand-copy of the rung list in this repository (morph#179). + # The rungs are read from examples/rungs.txt rather than listed here: a + # literal `pastebin bookmarks polls` would be a third hand-copy of the + # rung list in this repository, and the two above are already one too + # many. # # The gate is now the presence of `examples//gui_wasm/`, not of # `examples//`. Two cases it has to tell apart, and the directory @@ -193,8 +194,8 @@ jobs: fi done <<< "$rungs" # A loop that named no target at all would pass this step while - # building nothing, which is the same shape of defect as the filter - # that started morph#179. At least one rung has a WASM client. + # building nothing -- the same shape of defect as a path filter that + # matches nothing. At least one rung has a WASM client. if [ "$built" -eq 0 ]; then echo "::error::no ladder__gui_wasm target was built -- the named-target tripwire matched nothing" exit 1 @@ -209,7 +210,7 @@ jobs: run: | # pipefail because `find` over a build tree the previous step was # supposed to fill is a claim worth failing on: without it this step - # prints nothing and exits 0 whether the directory is empty or absent - # (morph#730). + # prints nothing and exits 0 whether the directory is empty or + # absent. set -o pipefail find build-wasm-ladder -name '*.wasm' -o -name '*.html' | sort diff --git a/cmake/CompileCache.cmake b/cmake/CompileCache.cmake index 181a47721..8136bc023 100644 --- a/cmake/CompileCache.cmake +++ b/cmake/CompileCache.cmake @@ -40,7 +40,7 @@ # the block below. That alone still leaves a genuinely clean machine uncached, # because a launcher with no daemon to talk to caches nothing — # -DFASTCACHE_AUTO_START=ON additionally stages and starts a fastcached daemon -# in the background when none answers at FASTCACHE_ADDR (issue #90); off by +# in the background when none answers at FASTCACHE_ADDR; off by # default, and independently of auto-install, since starting a background # process is a bigger side effect than downloading a file and CI relies on no # daemon answering by default. To disable everything: -DUSE_COMPILER_CACHE=OFF. @@ -79,7 +79,7 @@ if(DEFINED CMAKE_CXX_COMPILER_LAUNCHER OR DEFINED CMAKE_C_COMPILER_LAUNCHER) # all: the preset re-applies its cacheVariables on the reconfigure that # --fresh triggers, so the launcher comes back pinned and the tree is gone # for nothing. -U clears it there too, until the preset is next run. - # All of the above measured on an isolated harness, morph#592. + # All of the above measured on an isolated harness. if(DEFINED CACHE{CMAKE_CXX_COMPILER_LAUNCHER} OR DEFINED CACHE{CMAKE_C_COMPILER_LAUNCHER}) message(STATUS "[cache] That value comes from the CMake cache (a -D, a preset, or an older configure); " "to let this module choose instead, reconfigure the same build directory with " @@ -775,7 +775,7 @@ endfunction() # Stage and start a fastcached daemon in the background when FASTCACHE_ADDR is # otherwise unreachable, so FASTCACHE_AUTO_INSTALL's launcher has something to -# talk to on a genuinely clean machine (issue #90). Nothing here may fail a +# talk to on a genuinely clean machine. Nothing here may fail a # configure, exactly like _fc_auto_install_fastcache_cc: every failure is one # status line and a fall-through to the probe finding no daemon, which is the # behaviour without FASTCACHE_AUTO_START at all. diff --git a/cmake/DepCache.cmake b/cmake/DepCache.cmake index a8b88b40b..665738b0c 100644 --- a/cmake/DepCache.cmake +++ b/cmake/DepCache.cmake @@ -1,4 +1,4 @@ -# ── A shared source cache for FetchContent dependencies (morph#552) ────────── +# ── A shared source cache for FetchContent dependencies ────────────────────── # # Every configure in CI clones `glaze`, `Catch2`, `Lightweight` and # `doxygen-awesome-css` again from github.com. One run configures more than a @@ -42,7 +42,7 @@ endif() # include() is idempotent; every call site already does this too. include(FetchContent) -# ── Declaring and caching, split (morph#712) ───────────────────────────────── +# ── Declaring and caching, split ───────────────────────────────────────────── # # `morph_declare_dep` is what call sites use; `morph_cache_dep` below is the # caching half and is called only by it (and directly by @@ -60,10 +60,10 @@ include(FetchContent) # Why this wrapper exists at all: before it, every dependency wrote its revision # twice -- once as `morph_cache_dep`'s `tag`, once as the `GIT_TAG` of the # `FetchContent_Declare` beside it -- and the two could disagree. The divergence -# was asymmetric in the worst way: a warm cache served the first, an uncached -# configure fetched the second, both successfully and with no diagnostic -# anywhere. morph#693 added a gate that compared the two copies; this removes -# the second copy, so there is nothing left to disagree. +# would be asymmetric in the worst way: a warm cache serves the first, an +# uncached configure fetches the second, both successfully and with no +# diagnostic anywhere. Comparing the two copies in a gate is possible; having +# only one copy leaves nothing to disagree. # # `GIT_REPOSITORY` and `GIT_TAG` are therefore refused in ARGN rather than # forwarded: passing either would re-create the second copy inside the one call @@ -83,7 +83,7 @@ function(morph_declare_dep name repository tag) "morph_declare_dep(${name} ...) was passed ${_argument} as an extra " "argument. The repository and the tag are this call's own second and " "third arguments, and stating either of them twice is the divergence " - "this function exists to make unwritable (morph#712): the cache keys " + "this function exists to make unwritable: the cache keys " "on what it is handed, FetchContent fetches what it is handed, and a " "warm cache would then build a different revision than a cold one, " "both successfully.") @@ -173,7 +173,7 @@ function(morph_cache_dep name repository tag) if(EXISTS "${_stamp}") set(FETCHCONTENT_SOURCE_DIR_${_upper} "${_dir}" CACHE PATH - "Cached ${name} source tree (morph#552)" FORCE) + "Cached ${name} source tree" FORCE) message(STATUS "morph: dep cache: ${name} from ${_dir}") endif() endfunction() diff --git a/cmake/compiler_options.cmake b/cmake/compiler_options.cmake index a98061677..00de1f525 100644 --- a/cmake/compiler_options.cmake +++ b/cmake/compiler_options.cmake @@ -7,8 +7,8 @@ include(CheckCXXCompilerFlag) # gate written `$` matches nothing on a default macOS # toolchain. That is not hypothetical: it is how this project's *entire* # warning set -- -Weverything, every suppression under it, and -Werror -- -# silently stopped reaching the compile line on macOS, with no diagnostic of -# any kind (issue #298). A generator expression that fails to match is +# can silently stop reaching the compile line on macOS, with no diagnostic of +# any kind. A generator expression that fails to match is # indistinguishable from one that matches nothing on purpose. # # The shape of this file is the fix for that failure mode, not just the extra @@ -158,7 +158,7 @@ elseif(MORPH_COMPILER_FAMILY STREQUAL "Clang") _morph_clang_suppression_if_supported(-Wno-missing-designated-field-initializers) _morph_clang_suppression_if_supported(-Wno-nrvo) # not eliding a trivial-type copy on return # -Wshadow-uncaptured-local does NOT mean "a lambda parameter shadowing an - # uncaptured local", which is what this line claimed until morph#662. It is + # uncaptured local", which is the obvious reading and the wrong one. It is # clang's group for *any* declaration inside a lambda with no # capture-default that shadows an enclosing local the lambda did not # capture — the parameter case is one of four. Measured on clang 22.1.8, @@ -195,14 +195,13 @@ elseif(MORPH_COMPILER_FAMILY STREQUAL "Clang") # -isystem). That cleanup is tracked separately; do not fold it into an # unrelated change. # - # One consequence is recorded rather than fixed here (morph#662, still - # open): emsdk 3.1.56's older clang files `declaration shadows a structured - # binding` under plain -Wshadow instead, so the WASM leg is the only leg - # that enforces that one row of the table above. That statement is read off - # the CI log of run 35573507189, not reproduced locally — no emsdk - # toolchain is available here. It also currently enforces nothing in - # practice: morph#661 fixed the only four structured-binding shadows in the - # tree, and the measurement above found zero remaining. + # One consequence is recorded rather than fixed here: emsdk 3.1.56's older + # clang files `declaration shadows a structured binding` under plain + # -Wshadow instead, so the WASM leg is the only leg that enforces that one + # row of the table above. That statement is read off a CI log, not + # reproduced locally — no emsdk toolchain is available here. It also + # enforces nothing in practice: the measurement above finds zero + # structured-binding shadows left in the tree. list(APPEND MORPH_WARNING_FLAGS -Wno-shadow-uncaptured-local -Wno-documentation-unknown-command @@ -228,7 +227,7 @@ elseif(MORPH_COMPILER_FAMILY STREQUAL "Clang") # annotate the mutex-guarded members throughout, suppress the # diagnostic rather than let an unrelated libc++ upgrade break # every downstream target that transitively includes these - # headers (issue #64). + # headers. -Wno-thread-safety-negative ) @@ -380,8 +379,8 @@ if(MORPH_COMPILER_FAMILY STREQUAL "unknown") # MORPH_ENABLE_STRICT_COMPILATION is a promise the build cannot keep on a # compiler nothing here recognises: strict mode means warnings-as-errors, # and there are no warnings to make errors of. Refuse rather than configure - # a build that reports strict and enforces nothing — the exact shape of - # issue #298. Turning strict off downgrades this to a warning. + # a build that reports strict and enforces nothing. Turning strict off + # downgrades this to a warning. set(_morph_unknown_id_message "morph: warnings: CMAKE_CXX_COMPILER_ID='${CMAKE_CXX_COMPILER_ID}' matches no " "warning set in cmake/compiler_options.cmake, so this build would get the " @@ -439,7 +438,7 @@ endif() # # once per moc'd class, and -Werror (above) turns every AUTOMOC target in the # project into a build failure -- ladder__gui_lib, and with it every -# ladder__tests binary that links one (issue #372). +# ladder__tests binary that links one. # # AUTOMOC_PATH_PREFIX makes moc emit the header path relative to the include # directory it was found under instead ("budget_presenter.hpp", @@ -447,9 +446,9 @@ endif() # own -I set and never ascends out of the build tree. That removes the # ambiguity rather than the diagnostic: -Wno-shadow-header would silence this # case, but it is also the only thing that reports a genuine cross-checkout -# header pickup, which in this layout is a reachable state (issue #372's triage -# demonstrates a moc TU compiling against the *outer* checkout's header once -# the diagnostic is suppressed). scripts/check_automoc_includes.sh is the +# header pickup, which in this layout is a reachable state: with the diagnostic +# suppressed, a moc TU has been observed compiling against the *outer* +# checkout's header. scripts/check_automoc_includes.sh is the # regression gate: it fails on any generated moc include that ascends. # # It only reaches headers that actually sit under one of their target's @@ -487,9 +486,9 @@ endfunction() # @brief Assert the warning set actually reached every apply_warnings() target. # # Call once from the top-level CMakeLists.txt, after every add_subdirectory(). -# This is the guard issue #298 asked for: the original bug was a *silent* -# mismatch, so the fix is not only to name AppleClang but to make "the flags -# did not arrive" a configure-time failure. Reads COMPILE_OPTIONS back off +# The failure this guards is *silent*, so naming every compiler id correctly is +# not enough on its own: "the flags did not arrive" has to be a configure-time +# failure. Reads COMPILE_OPTIONS back off # each target rather than trusting that apply_warnings() did what it looks # like it does. function(morph_verify_warning_flags) @@ -510,7 +509,7 @@ function(morph_verify_warning_flags) "morph: warnings: target '${_target}' went through apply_warnings() but " "its COMPILE_OPTIONS do not contain '${MORPH_WARNING_SENTINEL}'. The " "warning set is not reaching the compile line — see " - "cmake/compiler_options.cmake (issue #298).") + "cmake/compiler_options.cmake.") endif() endforeach() list(LENGTH _targets _morph_target_count) @@ -519,14 +518,14 @@ function(morph_verify_warning_flags) "${_morph_target_count} target(s)") endfunction() -# `-fno-sanitize-recover=undefined` is load-bearing, not tuning (morph#541). +# `-fno-sanitize-recover=undefined` is load-bearing, not tuning. # UndefinedBehaviorSanitizer *recovers* by default: it prints the diagnostic # and lets the program carry on to exit 0, so a job that judges a run by its # exit status reports success over a build full of undefined behaviour. # Measured: a TU constructing `Rational{INT64_MIN, DecimalPlaces{2}}` printed # three `runtime error: negation of -9223372036854775808` lines on the -# `clang-ubsan` leg and exited 0 -- which is how morph#537's defect survived a -# sanitizer job that was green throughout. +# `clang-ubsan` leg and exited 0 -- which is how a real defect survives a +# sanitizer job that is green throughout. # # It belongs here rather than in a per-job `UBSAN_OPTIONS=halt_on_error=1` # because the asan arm is `-fsanitize=address,undefined` and so carries @@ -554,9 +553,9 @@ function(apply_sanitizers target mode) target_link_options(${target} PRIVATE -fsanitize=undefined -fno-sanitize-recover=undefined) else() - # No silent fall-through (morph#541). Without this arm, -DAF_SANITIZER=msan, - # =ASAN or a typo produced a *fully uninstrumented* build that configured, - # compiled and ran the entire suite green -- a sanitizer job reporting + # No silent fall-through. Without this arm, -DAF_SANITIZER=msan, + # =ASAN or a typo produces a *fully uninstrumented* build that configures, + # compiles and runs the entire suite green -- a sanitizer job reporting # success having sanitized nothing, which is the failure mode this # repository has hit most often. A configure-time error is the only place # the mistake is still cheap: by build time every binary looks normal, and @@ -570,7 +569,7 @@ function(apply_sanitizers target mode) endif() endfunction() -# ── A coverage build does not share a compiler cache (morph#426) ───────────── +# ── A coverage build does not share a compiler cache ───────────────────────── # # Set before cmake/CompileCache.cmake is included, so its option() -- which # honours a normal variable of the same name under CMP0077 -- picks this up as @@ -612,7 +611,7 @@ endfunction() # the cost of rewriting the filter mechanism whose silent shrinkage this issue # is about was the worse trade. # -# The same trade, on the same flag family, for __FILE__ (morph#775). `__FILE__` +# The same trade, on the same flag family, for __FILE__. `__FILE__` # is expanded at compile time and baked into the object, Catch2 records it per # TEST_CASE, and a cache hit served across worktrees therefore reports failures # against a directory that may hold a different revision of the file or no @@ -656,14 +655,14 @@ if(AF_COVERAGE) "(CXX='${CMAKE_CXX_COMPILER_LAUNCHER}'), so this build caches " "regardless of USE_COMPILER_CACHE. Safe on a single checkout; on a " "machine with several worktrees of this repository a cache hit can " - "carry another worktree's source paths into the coverage mapping " - "(morph#426), which scripts/check_coverage_roots.sh will catch.") + "carry another worktree's source paths into the coverage mapping, " + "which scripts/check_coverage_roots.sh will catch.") elseif(NOT DEFINED USE_COMPILER_CACHE) set(USE_COMPILER_CACHE OFF) message(STATUS "morph: coverage: compiler cache disabled by default -- a shared cache " "can serve objects built in another worktree, whose absolute source " - "paths then match none of scripts/coverage.sh's filters (morph#426). " + "paths then match none of scripts/coverage.sh's filters. " "Pass -DUSE_COMPILER_CACHE=ON to override where the cache is known " "not to be shared across checkouts.") elseif(USE_COMPILER_CACHE) @@ -680,8 +679,8 @@ if(AF_COVERAGE) "morph: coverage: USE_COMPILER_CACHE is ON for a coverage build. If it " "is shared across checkouts it can serve objects compiled in another " "worktree, whose absolute source paths match none of " - "scripts/coverage.sh's filters and are dropped from the report " - "(morph#426). This is expected on a single-checkout CI runner; on a " + "scripts/coverage.sh's filters and are dropped from the report. " + "This is expected on a single-checkout CI runner; on a " "developer machine with several worktrees, reconfigure with " "-DUSE_COMPILER_CACHE=OFF, or delete this build tree so the coverage " "default applies.") @@ -700,12 +699,12 @@ endif() # data that was merged and then dropped on the floor, and morph_qt_tests, # morph_offline_sqlite_tests and morph_net_qt_interop_tests were not even # instrumented, so include/morph/net contributed zero files to the uploaded -# report while eight test files drove it (morph#403). +# report while eight test files drove it. # -# That is the third time a hand-maintained list in scripts/coverage.sh has -# rotted -- morph#141 (rungs 2-4 never added) and morph#179 (the rung list had -# drifted past ledger and lims) were the first two, and both were fixed by -# deleting the copy and deriving the list instead. This is the same fix for the +# A hand-maintained list in scripts/coverage.sh has rotted three times this way +# -- rungs never added, a rung list drifting behind examples/rungs.txt, and this +# one -- and each time the repair was to delete the copy and derive the list +# instead. This is that fix for the # test-executable list: the build system already knows which binaries it # instrumented, so it writes them out (see # morph_write_coverage_object_manifest below) and coverage.sh reads them. A @@ -724,7 +723,7 @@ endif() # (morph_tests, morph_net_tests, morph_qt_tests, morph_offline_sqlite_tests, # morph_net_qt_interop_tests, ladder_common_tests, ladder__tests) is a # test binary. Deriving it from the name rather than from a per-call flag is -# the same move that fixed morph#179: a new test executable is registered by +# the same move: a new test executable is registered by # being named like one, with nothing to remember and nothing to forget. # # `TEST` forces registration for a binary the convention cannot reach, and the @@ -749,7 +748,7 @@ function(apply_coverage target) endif() # -fcoverage-mcdc is deliberately absent, and this is the record of that - # decision (morph#404 asks for one either way). + # decision. # # It works. Measured on clang 22.1.8 against tests/test_bridge_local.cpp, # compiled with this exact flag set plus -fcoverage-mcdc: the compile takes @@ -783,7 +782,7 @@ function(apply_coverage target) # a question of the two reasons above, not of toolchain availability. # `-fprofile-update=atomic` is not a tuning knob here -- without it the # branch numbers this build produces over multithreaded code are wrong, - # and wrong in the direction that reports coverage nobody has (morph#754). + # and wrong in the direction that reports coverage nobody has. # # llvm-cov does not count the second operand of a short-circuit `||` # directly. It *derives* that arm by subtracting one counter from another, @@ -856,7 +855,7 @@ endfunction() # exist on disk. An AF_COVERAGE build that *does* build the test suite and # still registered nothing is a configuration error rather than an empty # report, because an object list with nothing in it is exactly the -# silently-shrinking figure morph#403 was about. +# silently-shrinking figure this manifest exists to prevent. function(morph_write_coverage_object_manifest) if(NOT AF_COVERAGE) return() @@ -867,7 +866,7 @@ function(morph_write_coverage_object_manifest) "morph: coverage: AF_COVERAGE is ON and the test suite is being " "built, but no target was registered as a coverage object. " "scripts/coverage.sh would then have no binary to map profile " - "data through and would report coverage over nothing (morph#403). " + "data through and would report coverage over nothing. " "A test executable registers itself by being named _tests, " "or explicitly with apply_coverage( TEST).") endif() @@ -933,7 +932,8 @@ endfunction() # Registered here rather than called from CMakeLists.txt so the manifest and the # function that fills it stay in one file: a writer that has to be invoked by -# hand from another directory is the same shape of coupling morph#403 was about. +# hand from another directory is the same coupling that lets the object list +# shrink unnoticed. # DEFER on this directory runs the call after every add_subdirectory() of the # scope that included this file completes, which is when the last # apply_coverage() has run. diff --git a/cmake/morph_add_rung.cmake b/cmake/morph_add_rung.cmake index ef39df4f5..4c3ca81ff 100644 --- a/cmake/morph_add_rung.cmake +++ b/cmake/morph_add_rung.cmake @@ -27,7 +27,7 @@ # -L takes a regex, not an exact label. End-to-end journeys additionally get # "journey" from a small generated post-pass. See the catch_discover_tests # call below for why the label cannot be a two-value LABELS, and why the rung -# label is not applied by the post-pass (morph#173). +# label is not applied by the post-pass. # # The name prefix is what makes each case's ctest entry unique. A ctest name # is global to the build tree -- not scoped to the target or the directory it @@ -36,9 +36,9 @@ # one name are both reached by every by-name operation, and # `set_tests_properties( PROPERTIES LABELS ...)` is one of them: CTest # *appends* labels, so the first-registered entry accumulated later rungs' -# labels too and `ctest -L ladder-` over-selected -- running another -# rung's binary alongside its own, with nothing to say so (morph#464: crm -# selected 180 cases while owning 168). A failure line, `--output-junit` and +# labels too and `ctest -L ladder-` over-selects -- running another +# rung's binary alongside its own, with nothing to say so (measured once as crm +# selecting 180 cases while owning 168). A failure line, `--output-junit` and # CDash also identify a test by name alone, so a duplicate could not say which # binary failed. scripts/check_ctest_name_collisions.sh is the gate that keeps # this property true; ci.yml's ladder-tests job runs it on the all-rungs @@ -531,16 +531,16 @@ function(morph_add_rung) catch_discover_tests(ladder_${_rung}_tests DISCOVERY_MODE POST_BUILD # "." on every discovered ctest name, so no two rungs' - # entries can share one (morph#464 -- see the header comment - # for what a shared name did to `ctest -L ladder-`). + # entries can share one (see the header comment for what a + # shared name does to `ctest -L ladder-`). # One word, no space and no `;`: TEST_PREFIX rides the same # `-D VAR=...` channel as PROPERTIES below, whose flattening # the comment there documents. Catch2 prefixes the *ctest* # name only -- the filter argument it passes back to the # binary stays the unprefixed test name # (CatchAddTests.cmake's `escaped_name`), so this changes - # nothing about which case each entry runs, and it is not a - # fix for the `~`-prefix hazard morph#466 covers. + # nothing about which case each entry runs, and it does not + # address the `~`-prefix hazard. TEST_PREFIX "${_rung}." DL_PATHS "${_qt_bin_dir}" # One label, not two, and the *rung* one. catch_discover_tests @@ -564,9 +564,9 @@ function(morph_add_rung) # (`list(APPEND tests "${prefix}${plain_name}${suffix}")`), so # a TEST_CASE whose name contains a `;` is flattened into two # fragments that name no test, and set_tests_properties then - # silently applies to nothing. That was morph#173: the case - # kept `ladder` and never gained `ladder-`, so - # `ctest -L ladder-` under-selected without saying so. + # silently applies to nothing: the case keeps `ladder` and + # never gains `ladder-`, so `ctest -L ladder-` + # under-selects without saying so. PROPERTIES LABELS ladder-${_rung} TIMEOUT 120 RESOURCE_LOCK morph_ladder_test_db ) # `journey` still needs a post-pass: it applies to some cases and @@ -574,12 +574,12 @@ function(morph_add_rung) # express. It therefore inherits the flattening limitation # described above — a journey case whose name contained a `;` would # keep its rung label but never gain `journey`. Rather than let - # that go quiet the way morph#173 did, it is rejected at configure - # time by the guard below. + # that go quiet, it is rejected at configure time by the guard + # below. # # set_tests_properties *appends* to LABELS rather than replacing # them -- the same append that let two same-named ctest entries - # accumulate each other's rung labels (morph#464). The journey + # accumulate each other's rung labels. The journey # branch restates the rung label alongside `journey` anyway: the # resulting repeat of `ladder-` is inert (`-L` asks whether # a label is present, not how often), and restating it keeps the @@ -592,8 +592,8 @@ function(morph_add_rung) message(FATAL_ERROR "morph_add_rung(${_rung}): a \"Journey: \" TEST_CASE name contains a " "semicolon. CMake cannot round-trip that through Catch2's discovered " - "test list, so the case would silently never gain its `journey` label " - "(morph#173). Rename it.\n File: ${_journey_src}\n Name: ${_bad_journey_names}") + "test list, so the case would silently never gain its `journey` label. " + "Rename it.\n File: ${_journey_src}\n Name: ${_bad_journey_names}") endif() endforeach() file(GENERATE @@ -634,8 +634,8 @@ endforeach() # LLVM_PROFILE_FILE through QProcess and exit normally, so they do # write profile data -- which llvm-cov could not map to anything until # this registration existed, while scripts/coverage.sh named - # examples//src among its SOURCES regardless. Exactly the - # morph#403 shape, and invisible to + # examples//src among its SOURCES regardless -- a coverage + # figure computed over sources no registered binary reaches. Invisible to # scripts/check_coverage_objects.sh, because a binary reached through a # compile definition appears in no ctest command. Same reasoning, and # the same TEST keyword, as tests/qt's qt_test_server/qt_test_client. diff --git a/cmake/morph_demote_interface_includes.cmake b/cmake/morph_demote_interface_includes.cmake index 02087d2d9..4470af4b6 100644 --- a/cmake/morph_demote_interface_includes.cmake +++ b/cmake/morph_demote_interface_includes.cmake @@ -8,7 +8,7 @@ # morph_demote_lightweight_odbc_includes() # The one caller that matters, applied to the fetched `Lightweight` target. # -# ── Why this exists (morph#438) ────────────────────────────────────────────── +# ── Why this exists ────────────────────────────────────────────────────────── # # The pinned Lightweight (bbb972a78e1962b968a2c6ad93f7dade736eaa01) resolves # unixODBC with `pkg_check_modules(ODBC REQUIRED odbc)` and then propagates the diff --git a/cmake/tsan.supp b/cmake/tsan.supp index 481da77c4..6a1cf82de 100644 --- a/cmake/tsan.supp +++ b/cmake/tsan.supp @@ -7,7 +7,7 @@ # * the `clang-tsan` **ctest preset** in CMakePresets.json, through # `${sourceDir}` so it resolves from any working directory -- a relative # path does not survive test discovery, which that preset's description -# explains at length (morph#688); +# explains at length; # * the `Test` step of ci.yml's `linux-sanitizers` job; # * the `Test` step of ci.yml's `kanban-tsan` job. # @@ -19,23 +19,23 @@ # # ── What this file is not, and what second_deadlock_stack=1 buys ─────────── # -# Both points below are about morph#578, an intermittent `lock-order-inversion` -# seen once in six full `morph_tests` runs whose stack was lost, and they are +# Both points below are about an intermittent `lock-order-inversion` -- seen +# once in six full `morph_tests` runs, with its stack lost -- and they are # written here because this is the file the next person will open. # # 1. Every `race:` entry below is a **suppression**, so none of them could # have hidden that report: TSan matches `mutex:`/`deadlock:` entries -# against lock-order findings, and there are none here. The issue says -# this and the issue is right. Whether TSAN_OPTIONS was set at the time -# is irrelevant to whether the warning could appear. +# against lock-order findings, and there are none here. Whether +# TSAN_OPTIONS was set at the time is irrelevant to whether the warning +# could appear. # -# 2. `second_deadlock_stack=1` **is** set (morph#736 set it, in all three -# places above), and what it buys is measured rather than assumed. A +# 2. `second_deadlock_stack=1` **is** set, in all three places above, and +# what it buys is measured rather than assumed. A # 23-line standalone control -- two static mutexes taken in both orders, # clang 22.1.8, x86-64 Linux -- reports the inversion either way and # prints *both* `acquired here while holding` stacks either way, so the -# option is **not** why morph#578's report was uninformative; a -# `grep -A 60 | head -70` over the run is, as that issue states. What it +# option is **not** why that report was uninformative; a +# `grep -A 60 | head -70` over the run is. What it # adds, exactly, is 24 further lines carrying two more stacks (47 report # lines -> 71 on that control; 36 -> 55 on the two-mutex inversion # measured for the preset): @@ -43,20 +43,20 @@ # Mutex M0 previously acquired by the same thread here: # Mutex M1 previously acquired by the same thread here: # -# i.e. where the *already-held* mutex of each pair was taken. morph#578's -# cycle has three mutexes, two of them at stack addresses, and those two +# i.e. where the *already-held* mutex of each pair was taken. The cycle +# seen here has three mutexes, two of them at stack addresses, and those two # stacks are what would name the fixture or short-lived object holding # them. Without the option TSan prints only `Hint: use # TSAN_OPTIONS=second_deadlock_stack=1 to get more informative warning -# message` -- advice nobody can take after the fact, because morph#578 and -# morph#717 are intermittent and the run that fires is the only evidence +# message` -- advice nobody can take after the fact, because these +# inversions are intermittent and the run that fires is the only evidence # that will ever exist. It costs nothing measurable: 2M lock acquisitions -# under TSan took a median 0.275s without it and 0.273s with (morph#736). +# under TSan take a median 0.275s without it and 0.273s with. # # So: do not drop it while tidying a preset or a workflow env block. It is not # decoration, and the report that needs it cannot ask for it in advance. # -# ── libstdc++'s refcounted exception teardown (morph#476) ────────────────── +# ── libstdc++'s refcounted exception teardown ────────────────────────────── # # A `std::exception_ptr` is a refcounted handle to one heap-allocated # exception object, and the `std::runtime_error` inside it holds its message diff --git a/scripts/aggregate_lcov_branches.py b/scripts/aggregate_lcov_branches.py index c68d859d5..93fe63fd3 100755 --- a/scripts/aggregate_lcov_branches.py +++ b/scripts/aggregate_lcov_branches.py @@ -15,7 +15,7 @@ what `llvm-cov report` counts, so genuinely-uncovered branches stay uncovered and only the per-instantiation noise disappears. -It also strips a second, unrelated kind of noise (see morph#93): `Q_OBJECT` +It also strips a second, unrelated kind of noise: `Q_OBJECT` implicitly declares a static `tr()` overload for the enclosing class, whose coverage-map region llvm-cov attributes starting at the `Q_OBJECT` line and running to the start of whatever comes next in the file (the next class, or @@ -151,7 +151,7 @@ def main(): if lf == 0 and lh == 0: # llvm-cov's own verdict is "nothing here to count" (matches # `llvm-cov report`'s "Lines: 0, Cover: -"), but it still - # emitted stray FN/FNDA/DA records for this file (morph#93). + # emitted stray FN/FNDA/DA records for this file. # Keep only SF:, drop everything the block carried, including # the branch records just built above. out.append(block[0]) diff --git a/scripts/branch_partial_allowlist.json b/scripts/branch_partial_allowlist.json index 0f633b6e6..0f8ef5aee 100644 --- a/scripts/branch_partial_allowlist.json +++ b/scripts/branch_partial_allowlist.json @@ -6,19 +6,19 @@ "inverting the condition cannot fail a test, because nothing has ever observed the other", "way. scripts/check_branch_coverage.py counts them. Most are simply untested and belong on", "the backlog that gate prints. The ones here are different: they are unreachable by", - "construction, and the reason has to say why rather than asserting it (morph#404: 'a bare", - "suppression is not a disposition').", + "construction, and the reason has to say why rather than asserting it -- a bare", + "suppression is not a disposition.", "", "Each entry is audited in both directions on every coverage run, which is what stops this", "file becoming the suppression list it superficially resembles. An entry whose line is no", "longer partial fails the gate and must be deleted; an entry whose source text no longer", "exists fails too. `line` is a hint, not the key: the key is `source`, matched against the", - "file's current contents, because a comment citing a bare line number is the defect this", - "repository has now found three times (morph#349, morph#355, morph#419). If the code moves", + "file's current contents, because a comment citing a bare line number is a defect this", + "repository has found three separate times. If the code moves", "and the text is still unique, the gate resolves it and tells you the new number.", "", "The instrument is not deterministic, and an entry re-justified from ONE run is not", - "re-justified (morph#763). `-fprofile-update=atomic`, which apply_coverage() now passes,", + "re-justified. `-fprofile-update=atomic`, which apply_coverage() passes,", "removes the counter WRAP artifact -- llvm-cov derives the second operand of a", "short-circuit `||` by subtracting counters, and a concurrent non-atomic update makes that", "subtraction go negative and print an untaken arm as taken. It does not remove ordinary", @@ -28,8 +28,8 @@ " tree-wide partial lines 123 122 122 123 122", " the line that moves include/morph/core/detail/execute_order_gate.hpp, 4/3/3/4/3", "", - "Every other file was identical in all five. The morph#764 lane saw the same one-line", - "spread on a different file (socket_backend.hpp, 11/10), so which line jitters is itself", + "Every other file was identical in all five. The same one-line spread has been seen on a", + "different file (socket_backend.hpp, 11/10), so which line jitters is itself", "machine- and run-dependent. Consequence for anyone auditing this file: a one-line", "difference between two coverage reports is inside the noise, a figure quoted from a", "single run carries it, and an entry whose argument rests on an arm observed a handful of", @@ -99,25 +99,25 @@ "file": "include/morph/core/strand.hpp", "line": 323, "source": "if (iter != _strands.end() && iter->second == strand) {", - "reason": "Unreachable by construction given this class's lock discipline (core audit finding ST1, resolved to (b) by a concurrency-focused review pass after an initial (a)/(b)-undecided pass). `_strands` has exactly two mutation sites: the insert-if-absent `post()` reaches through `installStrand` (this file) and this exact block's own removal a few lines below -- an `extract` into `_spare` since morph#670, which detaches the entry at the same point the `erase` did -- both under `_mapMtx`. At most one lambda per `Strand` runs at a time (`post()` only schedules when `!strand->running`, and re-arming happens only through this same lambda's own `more` branch), so dispatch for one `Strand` is strictly serial; and only a strand's own currently-running lambda can erase its map entry (the erase fires only in the `!more` branch for the entry this frame just found under `_mapMtx`, and a concurrent `post(key)` while this lambda runs can only push onto the existing `Strand`, never replace it; a node parked in `_spare` is out of the map, so `find` cannot return it). Together these force `_strands.find(key)` to yield this exact strand whenever this line runs, so `iter->second == strand` cannot be false. No stress test needed: one was considered, but given the strength of the lock-discipline argument it would spend CI time re-confirming an already-proven invariant rather than searching for an unknown one." + "reason": "Unreachable by construction given this class's lock discipline (core audit finding ST1, resolved to (b) by a concurrency-focused review pass after an initial (a)/(b)-undecided pass). `_strands` has exactly two mutation sites: the insert-if-absent `post()` reaches through `installStrand` (this file) and this exact block's own removal a few lines below -- an `extract` into `_spare`, which detaches the entry at the point an `erase` would -- both under `_mapMtx`. At most one lambda per `Strand` runs at a time (`post()` only schedules when `!strand->running`, and re-arming happens only through this same lambda's own `more` branch), so dispatch for one `Strand` is strictly serial; and only a strand's own currently-running lambda can erase its map entry (the erase fires only in the `!more` branch for the entry this frame just found under `_mapMtx`, and a concurrent `post(key)` while this lambda runs can only push onto the existing `Strand`, never replace it; a node parked in `_spare` is out of the map, so `find` cannot return it). Together these force `_strands.find(key)` to yield this exact strand whenever this line runs, so `iter->second == strand` cannot be false. No stress test needed: one was considered, but given the strength of the lock-discipline argument it would spend CI time re-confirming an already-proven invariant rather than searching for an unknown one." }, { "file": "include/morph/core/backend.hpp", "line": 1302, "source": "if (const auto* inst = _instances.find(modelId)) {", - "reason": "Unreachable by construction given the `_changeAware`/`_instances` invariant (core audit finding BK2). `_changeAware` is an index over the instance directory: an id enters it in `createHolder` (this file, when the holder answers `isBackendChangeAware()`) in the same `_regMtx`-held critical section that files the instance, and leaves it in `deregisterModel` only when `InstanceDirectory::release` reports the instance actually destroyed. `notifyBackendChanged()` (this function) holds the same `_regMtx` while walking `_changeAware` and looking each id up at this line, so every id it walks is still live -- the null arm cannot occur without a code change that breaks that subset invariant. Formerly keyed on `_models`, the map morph#523 replaced with the directory; the invariant and its reason are unchanged." + "reason": "Unreachable by construction given the `_changeAware`/`_instances` invariant (core audit finding BK2). `_changeAware` is an index over the instance directory: an id enters it in `createHolder` (this file, when the holder answers `isBackendChangeAware()`) in the same `_regMtx`-held critical section that files the instance, and leaves it in `deregisterModel` only when `InstanceDirectory::release` reports the instance actually destroyed. `notifyBackendChanged()` (this function) holds the same `_regMtx` while walking `_changeAware` and looking each id up at this line, so every id it walks is still live -- the null arm cannot occur without a code change that breaks that subset invariant." }, { "file": "include/morph/core/detail/subscription_registry.hpp", "line": 147, "source": "if (owner && entry.type == type && owner->currentId.load() == mid.v && entry.sink) {", - "reason": "Scoped to the `owner` sub-condition only (core audit finding S3's `owner` arm) -- the `entry.sink` sub-condition on this same line was closed with a real test (see `tests/test_subscription.cpp`'s silent/loud-subscriber test, commit 0c7691a6). `publishResult` prunes any entry whose `binding.expired()` a few lines above this one, under the same `_mtx` lock this loop runs under, before the delivery loop here ever starts in the same locked call -- so `owner` (`entry.binding.lock()`) can only be null at this line if a binding expires in the narrow window between that prune and this specific entry's own `.lock()` call, both inside one uninterrupted critical section. That is a genuine TOCTOU race requiring true concurrent destruction under the lock's protection, the same class of issue as core audit finding O1 (`observability.hpp`'s `endSpan`), whose own entry left this file once a coverage run showed its arm taken. Verified empirically (commit 0c7691a6's own message): `llvm-cov` still shows this sub-condition's false arm at 0 hits even after adding the doc-recommended \"destroy the binding, then call publishResult\" test, because that recipe's destruction happens strictly before the call, not concurrently with it. Accepted as documented rather than forced with a flaky thread-race test. RE-READ under -fprofile-update=atomic (morph#763). The part of this reason at risk was not the allowlisted `owner` arm but the claim that the `entry.sink` sub-condition on the same line was closed by a real test -- a false arm observed exactly ONCE is precisely what a wrapped count fabricates, so this is the entry where a single run is least adequate and the one to spend runs on. Five CI-equivalent coverage runs, same binaries, same 2962 tests, atomic counters, all four sub-conditions of this line, identical in all five runs: `Branch (147:21): [True: 26, False: 0]` (`owner`, the arm this entry scopes), `Branch (147:30): [True: 24, False: 2]`, `Branch (147:52): [True: 21, False: 3]`, `Branch (147:88): [True: 20, False: 1]` (`entry.sink`). That `False: 1` is a real test hit, observed five times out of five and never absent, not a subtraction that wrapped. So the scoping is still correct: one sub-condition untaken, three exercised both ways. What would retire it: `147:21` reporting a non-zero False, or `147:88` losing its lone False hit, either of which changes which sub-condition this entry is about." + "reason": "Scoped to the `owner` sub-condition only (core audit finding S3's `owner` arm) -- the `entry.sink` sub-condition on this same line is closed with a real test (`tests/test_subscription.cpp`'s silent/loud-subscriber test). `publishResult` prunes any entry whose `binding.expired()` a few lines above this one, under the same `_mtx` lock this loop runs under, before the delivery loop here ever starts in the same locked call -- so `owner` (`entry.binding.lock()`) can only be null at this line if a binding expires in the narrow window between that prune and this specific entry's own `.lock()` call, both inside one uninterrupted critical section. That is a genuine TOCTOU race requiring true concurrent destruction under the lock's protection, the same class of issue as core audit finding O1 (`observability.hpp`'s `endSpan`), whose own entry left this file once a coverage run showed its arm taken. Verified empirically: `llvm-cov` still shows this sub-condition's false arm at 0 hits even after adding the doc-recommended \"destroy the binding, then call publishResult\" test, because that recipe's destruction happens strictly before the call, not concurrently with it. Accepted as documented rather than forced with a flaky thread-race test. RE-READ under -fprofile-update=atomic. The part of this reason at risk was not the allowlisted `owner` arm but the claim that the `entry.sink` sub-condition on the same line was closed by a real test -- a false arm observed exactly ONCE is precisely what a wrapped count fabricates, so this is the entry where a single run is least adequate and the one to spend runs on. Five CI-equivalent coverage runs, same binaries, same 2962 tests, atomic counters, all four sub-conditions of this line, identical in all five runs: `Branch (147:21): [True: 26, False: 0]` (`owner`, the arm this entry scopes), `Branch (147:30): [True: 24, False: 2]`, `Branch (147:52): [True: 21, False: 3]`, `Branch (147:88): [True: 20, False: 1]` (`entry.sink`). That `False: 1` is a real test hit, observed five times out of five and never absent, not a subtraction that wrapped. So the scoping is still correct: one sub-condition untaken, three exercised both ways. What would retire it: `147:21` reporting a non-zero False, or `147:88` losing its lone False hit, either of which changes which sub-condition this entry is about." }, { "file": "include/morph/core/remote.hpp", "line": 1416, "source": "if (_inFlightExecutes.compare_exchange_weak(current, current + 1, std::memory_order_relaxed)) {", - "reason": "Real but requires genuine thread contention to trigger -- accepted as documented rather than closed with a flaky test (core audit finding RM11). The false arm (the CAS lost the race and must retry) needs two threads to genuinely collide on the same atomic increment at the same instant; it is a real, reachable hazard the retry loop correctly handles, not dead code, but inherently non-deterministic to trigger from a test without exact thread-timing control. Same disposition class as `strand.hpp`'s ST1 above, and as core audit finding O1 (`observability.hpp`'s `endSpan`), whose entry left this file once a coverage run showed its arm taken: a stress test with many concurrent `execute()` calls against a tight `maxInFlightExecutes` limit would probably eventually hit it, but flakily. RE-READ under -fprofile-update=atomic (morph#763), which is the condition this disposition had never been measured under: the corruption direction is untaken -> appears taken, so an entry arguing \"real but never observed\" is exactly the kind that could have been resting on a wrapped count. It is not, and that is now a spread rather than a single reading -- a single run cannot tell a flag effect from run order (morph#763's own correction). Five CI-equivalent coverage runs, same binaries, same 2962 tests, atomic counters, on this line: `Branch (1416:21): [True: 3, False: 0]` in all five, identical to the digit. The false arm is taken by nothing in any run, so the disposition stands unchanged; had it read a wrapped 18.4E the entry would have been retired instead. What would retire it now: any run reporting a non-zero False here, which would mean the retry arm is reachable from the suite after all." + "reason": "Real but requires genuine thread contention to trigger -- accepted as documented rather than closed with a flaky test (core audit finding RM11). The false arm (the CAS lost the race and must retry) needs two threads to genuinely collide on the same atomic increment at the same instant; it is a real, reachable hazard the retry loop correctly handles, not dead code, but inherently non-deterministic to trigger from a test without exact thread-timing control. Same disposition class as `strand.hpp`'s ST1 above, and as core audit finding O1 (`observability.hpp`'s `endSpan`), whose entry left this file once a coverage run showed its arm taken: a stress test with many concurrent `execute()` calls against a tight `maxInFlightExecutes` limit would probably eventually hit it, but flakily. RE-READ under -fprofile-update=atomic, without which this disposition is not safe to trust: the corruption direction is untaken -> appears taken, so an entry arguing \"real but never observed\" is exactly the kind that can rest on a wrapped count. It does not, and the reading below is a spread rather than a single figure -- one run cannot tell a flag effect from run order. Five CI-equivalent coverage runs, same binaries, same 2962 tests, atomic counters, on this line: `Branch (1416:21): [True: 3, False: 0]` in all five, identical to the digit. The false arm is taken by nothing in any run, so the disposition stands unchanged; had it read a wrapped 18.4E the entry would have been retired instead. What would retire it now: any run reporting a non-zero False here, which would mean the retry arm is reachable from the suite after all." }, { "file": "include/morph/core/bridge.hpp", @@ -135,19 +135,19 @@ "file": "include/morph/net/socket_server.hpp", "line": 189, "source": "if (t.joinable()) {", - "reason": "Unreachable by construction (net audit, `socket_server.hpp` finding #6, re-verified against the current `close()` after the morph#451 fix serialized its whole body under `_closeMtx`). `_clientThreads` has exactly one push site (`acceptLoop()`, always a freshly-constructed, running `std::thread`) and this loop is the only place any entry is ever joined or detached. With `close()`'s entire body now serialized by `_closeMtx`, only one caller's `close()` can ever reach this loop: a second, later call observes `wasAlreadyClosing == true` and `!_acceptThread.joinable()` (already joined by the winner) and takes the early return above, before ever reaching the client-thread swap-and-join section this line is in. So every `std::thread` this loop iterates over is a fresh entry pushed by `acceptLoop()` that nothing has touched yet -- `joinable()` cannot be false here." + "reason": "Unreachable by construction (net audit, `socket_server.hpp` finding #6, verified against a `close()` whose whole body is serialized under `_closeMtx`). `_clientThreads` has exactly one push site (`acceptLoop()`, always a freshly-constructed, running `std::thread`) and this loop is the only place any entry is ever joined or detached. With `close()`'s entire body serialized by `_closeMtx`, only one caller's `close()` can ever reach this loop: a second, later call observes `wasAlreadyClosing == true` and `!_acceptThread.joinable()` (already joined by the winner) and takes the early return above, before ever reaching the client-thread swap-and-join section this line is in. So every `std::thread` this loop iterates over is a fresh entry pushed by `acceptLoop()` that nothing has touched yet -- `joinable()` cannot be false here." }, { "file": "include/morph/net/socket_server.hpp", "line": 224, "source": "if (closed.load() || !socket.valid()) {", - "reason": "The `!socket.valid()` disjunct is unreachable by construction (net audit, `socket_server.hpp` finding #7). `ClientConnection::socket` is set once at construction and never moved from or reassigned anywhere in this file (only method calls on it, never an assignment or `std::move`); `TcpSocket::valid()` is `_fd >= 0`, and `_fd` only becomes -1 in the move constructor/assignment and the destructor, neither of which can run while `sendText()` holds a `shared_ptr`. `shutdownBoth()` does not touch `_fd`. `closed.store(true)` is what every real teardown path sets first, so the `closed.load()` disjunct alone accounts for all of them. RESTORED in morph#754 after being deleted in morph#743, and the deletion is the interesting part. It was deleted because CI job 107031243709 (branch lane/core-698-699-524-572b-573s4 @ 87f7aedf, 2026-09-23 04:10Z) failed this gate with \"include/morph/net/socket_server.hpp:224 is allowlisted as an uncoverable partial branch, but it is not a partial line in this report\" -- the disjunct read as taken. That reading was an instrumentation artifact, not a fact about the code. morph#743 changed no file under include/morph/net at all, and the four neighbouring coverage reports of the same code -- jobs 106998755431 (11 partial lines in this file), 107048617566 (11), the master job 107084755468 (11), and a local CI-equivalent run on 6d6a5353 (11) -- all report it untaken. The cause: llvm-cov derives the second operand of a short-circuit || by subtracting counters rather than counting it, and the coverage build's counters were non-atomic, so concurrent updates make that subtraction go negative and wrap to a huge \"taken\" count. Demonstrated with a controlled probe: 12 threads over `if (flag.load() || !alwaysTrue())` reported `True: 18.4E` for the never-taken arm in 8 runs of 8; the same binary single-threaded reported `True: 0` in 3 of 3, and the same 12 threads built with -fprofile-update=atomic reported `True: 0` in 3 of 3. cmake/compiler_options.cmake's apply_coverage() now passes that flag, which is what makes this entry stable rather than flaky. Re-measured with it: `Branch (224:17): [True: 69, False: 1.52k]` and `Branch (224:34): [True: 0, False: 1.52k]`. What would make it reachable, and so retire this entry: any code that move-assigns or destroys ClientConnection::socket while another thread can be inside sendText() -- replacing the connection's socket on a reconnect, say, or dropping the shared_ptr discipline. If this gate reports the line non-partial again on a build carrying -fprofile-update=atomic, that is a real change and the entry should be deleted rather than argued with." + "reason": "The `!socket.valid()` disjunct is unreachable by construction (net audit, `socket_server.hpp` finding #7). `ClientConnection::socket` is set once at construction and never moved from or reassigned anywhere in this file (only method calls on it, never an assignment or `std::move`); `TcpSocket::valid()` is `_fd >= 0`, and `_fd` only becomes -1 in the move constructor/assignment and the destructor, neither of which can run while `sendText()` holds a `shared_ptr`. `shutdownBoth()` does not touch `_fd`. `closed.store(true)` is what every real teardown path sets first, so the `closed.load()` disjunct alone accounts for all of them. This entry survives a gate failure that looks like it retires it, so the artifact is recorded here: without -fprofile-update=atomic, a coverage run can report this disjunct as *taken* and fail the gate with \"include/morph/net/socket_server.hpp:224 is allowlisted as an uncoverable partial branch, but it is not a partial line in this report\", while four neighbouring reports of the same unchanged code all report it untaken (11 partial lines in this file, each time). The cause: llvm-cov derives the second operand of a short-circuit || by subtracting counters rather than counting it, so with non-atomic counters a concurrent update makes that subtraction go negative and wrap to a huge \"taken\" count. Demonstrated with a controlled probe: 12 threads over `if (flag.load() || !alwaysTrue())` reported `True: 18.4E` for the never-taken arm in 8 runs of 8; the same binary single-threaded reported `True: 0` in 3 of 3, and the same 12 threads built with -fprofile-update=atomic reported `True: 0` in 3 of 3. cmake/compiler_options.cmake's apply_coverage() passes that flag, which is what makes this entry stable rather than flaky. Re-measured with it: `Branch (224:17): [True: 69, False: 1.52k]` and `Branch (224:34): [True: 0, False: 1.52k]`. What would make it reachable, and so retire this entry: any code that move-assigns or destroys ClientConnection::socket while another thread can be inside sendText() -- replacing the connection's socket on a reconnect, say, or dropping the shared_ptr discipline. If this gate reports the line non-partial again on a build carrying -fprofile-update=atomic, that is a real change and the entry should be deleted rather than argued with." }, { "file": "include/morph/net/socket_server.hpp", "line": 267, "source": "if (!clientSocket) {", - "reason": "Real, reachable race (`tryAccept()` returning nullopt because the pending connection went away before it was taken), but accepted as documented rather than forced with a flaky test after extensive attempts (net audit, `socket_server.hpp` finding #10). Three different techniques were tried: a single real `TcpSocket::connect()` immediately followed by an abortive (`SO_LINGER{1,0}`) close (0/150 hits); a burst of many such attempts to build backlog depth (still 0 hits); and a burst of bare non-blocking `::connect()`+abort attempts skipping `TcpSocket::connect()`'s `getaddrinfo()`/poll overhead (960 attempts across 15 bursts, still 0 hits, with most connections resetting before the TCP handshake progressed far enough to make the listener readable at all, rather than after). No way was found, from outside the process, to reliably land in the specific narrow window this branch requires on this machine. Reported as attempted-and-left-open rather than forcing something flakier. RE-READ under -fprofile-update=atomic (morph#763), over several runs rather than one, because a single figure cannot be told apart from run order. Five CI-equivalent coverage runs, same binaries, same 2962 tests, atomic counters: `Branch (267:17): [True: 0, False: 3.80k / 3.80k / 3.82k / 3.75k / 3.78k]`, with line 268 (`continue`) at 0 executions in every run. The denominator moves with how many accepts the suite happens to perform -- 3.75k to 3.82k across the five, and 576 on the morph#764 lane's machine -- and the numerator does not move at all: tens of thousands of accepts across five runs, none of them nullopt. The disposition stands, and the figure it quotes is one no concurrent counter update can inflate. What would retire it: a True count above 0 in any run." + "reason": "Real, reachable race (`tryAccept()` returning nullopt because the pending connection went away before it was taken), but accepted as documented rather than forced with a flaky test after extensive attempts (net audit, `socket_server.hpp` finding #10). Three different techniques were tried: a single real `TcpSocket::connect()` immediately followed by an abortive (`SO_LINGER{1,0}`) close (0/150 hits); a burst of many such attempts to build backlog depth (still 0 hits); and a burst of bare non-blocking `::connect()`+abort attempts skipping `TcpSocket::connect()`'s `getaddrinfo()`/poll overhead (960 attempts across 15 bursts, still 0 hits, with most connections resetting before the TCP handshake progressed far enough to make the listener readable at all, rather than after). No way was found, from outside the process, to reliably land in the specific narrow window this branch requires on this machine. Reported as attempted-and-left-open rather than forcing something flakier. RE-READ under -fprofile-update=atomic, over several runs rather than one, because a single figure cannot be told apart from run order. Five CI-equivalent coverage runs, same binaries, same 2962 tests, atomic counters: `Branch (267:17): [True: 0, False: 3.80k / 3.80k / 3.82k / 3.75k / 3.78k]`, with line 268 (`continue`) at 0 executions in every run. The denominator moves with how many accepts the suite happens to perform -- 3.75k to 3.82k across the five, and 576 on another machine -- and the numerator does not move at all: tens of thousands of accepts across five runs, none of them nullopt. The disposition stands, and the figure it quotes is one no concurrent counter update can inflate. What would retire it: a True count above 0 in any run." }, { "file": "include/morph/net/socket_backend.hpp", diff --git a/scripts/check_automoc_includes.sh b/scripts/check_automoc_includes.sh index b0df0eada..f89357f07 100755 --- a/scripts/check_automoc_includes.sh +++ b/scripts/check_automoc_includes.sh @@ -2,7 +2,7 @@ # Usage: bash scripts/check_automoc_includes.sh [DIR...] # # Fails if any moc-generated source includes its class's header by a path that -# climbs out of its own directory -- see issue #372. +# climbs out of its own directory. # # moc writes the include for the header it was run on. Left to itself it writes # a path relative to the generated file, and since the generated file lives @@ -105,8 +105,7 @@ ${offenders} AUTOMOC include lint failed: the moc output above includes its header by a path that climbs out of its own directory. That path is also resolved against every -I entry, so it can pick up a same-named header from a different checkout -- and -Clang's -Wshadow-header makes it a hard error under this project's -Werror -(issue #372). +Clang's -Wshadow-header makes it a hard error under this project's -Werror. Two things produce this, and the second is the likelier one: diff --git a/scripts/check_branch_coverage.py b/scripts/check_branch_coverage.py index f324399f4..8207e5185 100644 --- a/scripts/check_branch_coverage.py +++ b/scripts/check_branch_coverage.py @@ -5,8 +5,8 @@ python3 scripts/check_branch_coverage.py [LCOV] [--objects coverage_objects.txt] python3 scripts/check_branch_coverage.py --self-test -Why this exists (morph#404) ---------------------------- +Why this exists +--------------- This repository measures branch coverage and then gates on lines. Branch data is produced and deliberately preserved -- scripts/coverage.sh exports LCOV BRDA records and runs scripts/aggregate_lcov_branches.py to collapse llvm-cov's @@ -95,8 +95,8 @@ # Previously measured on 2026-09-02, over the CI coverage leg's full # configure (MORPH_BUILD_NET/OFFLINE_SQLITE/QT/LADDER=ON, # MORPH_LADDER_RUNGS=all) with all 2435 ctest cases passing -- and, -# critically, after morph#403: include/morph/net contributed zero files to -# every report before that, so its 338 branches and 66 partial lines were +# critically, with include/morph/net present in the report: it contributed +# zero files to earlier ones, so its 338 branches and 66 partial lines were # new to the denominator rather than new to the code. Kept here as the # prior data point; the floors below are all from the 2026-09-07 run. FLOORS = { @@ -112,8 +112,8 @@ "include/morph/util": (93.0, 96.39), } -# The library as a whole, which is what morph#404 asks to be "reported as its own -# number and carry a target". +# The library as a whole, reported as its own number and carrying its own +# target. TOTAL_FLOOR = 92.0 TOTAL_MEASURED = 95.95 @@ -125,15 +125,15 @@ # morph_tests compiles nothing under include/morph/net or include/morph/qt: # those come from tests/net and tests/qt, gated behind MORPH_BUILD_NET and # MORPH_BUILD_QT, both `option(... OFF)`. Without this table the vacuity check -# below fails that configure while blaming morph#403 -- a fixed defect -- for an -# option simply being off, which is the same shape of wrong diagnosis as a +# below fails that configure and blames a dropped subsystem for an option simply +# being off -- a wrong diagnosis that costs whoever reads it the same as a # citation that has drifted. # # The mapping is checked in both directions rather than trusted, so it cannot # rot into a permanent skip: a subsystem whose binary is absent must have *no* # records (otherwise the mapping is stale and this gate says so), and one whose -# binary is present must have records (which is the morph#403 shape and stays -# fatal). A subsystem not named here is required unconditionally, so a new +# binary is present must have records (a subsystem silently absent from the +# report, which stays fatal). A subsystem not named here is required unconditionally, so a new # directory under include/morph defaults to the strict side. OPTIONAL_SUBSYSTEMS = { "include/morph/net": "morph_net_tests", @@ -207,8 +207,8 @@ def summarise(files): # # The second half of that sentence used to read "and narrow enough that two # occurrences of the same statement in neighbouring overloads do not both fall -# inside it". **That is not true of any of the five citations morph#711 -# migrated**, and it is recorded here rather than left for the next person to +# inside it". **That is not true of any of the five citations in this +# allowlist**, and it is recorded here rather than left for the next person to # rediscover. Measured on each pair, as the gap between the two occurrences: # # include/morph/core/backend.hpp registerCount 1149 / 1168 19 @@ -227,8 +227,8 @@ def summarise(files): # The failure direction is still the safe one -- an unusable window is refused # as still-ambiguous, never resolved to a guess -- so this is a usability defect # in `context`, not a correctness one. Widening the window makes it worse and -# narrowing it makes `context` unable to reach a function signature at all; -# morph#701's design note is where a better disambiguator belongs. +# narrowing it makes `context` unable to reach a function signature at all. A +# better disambiguator than a window would be a separate design. CONTEXT_WINDOW = 40 @@ -250,15 +250,12 @@ def resolve_allowlist_source_line(repo_root, path, hint, wanted, allowlist_path, """Resolve one allowlist entry's `source` text to its current line number. Used by this module's resolve_allowlist(), keyed on partial branch lines. - A second caller keyed on throw/catch sites (morph#406) shared it until the - meta-gates were removed; this is the only caller now. - - The sharing was deliberate while it lasted, and the reason it existed is - still the reason this function is worth keeping correct: the "moved line"/ - "ambiguous match" resolution here is exactly the fix for a defect this - repository has found three times over in an allowlist keyed by line number - alone (morph#349, morph#355, morph#419), and a second, independently - maintained copy of that fix is how the class gets a fourth chance. + It is the only caller. Any second allowlist keyed on source text belongs + here too rather than in a copy of this logic: the "moved line"/"ambiguous + match" resolution below is the fix for a defect this repository has found + three separate times in an allowlist keyed by line number alone, and a + second, independently maintained copy of that fix is how the class gets a + fourth chance. Returns the resolved line number on success. Returns `None` and appends to `failures` on any failure: a missing source file, `wanted` text that @@ -269,12 +266,12 @@ def resolve_allowlist_source_line(repo_root, path, hint, wanted, allowlist_path, an update is still needed before the entry should be trusted -- see the "has moved to line" message below. - Ambiguity, and the `context` field (morph#701) - ---------------------------------------------- - Until morph#701 this function answered an ambiguous citation with whatever - the entry had already said: `if hint in matches: return hint` accepted *any* - occurrence of the text, so a hint that named the wrong one of three passed - exactly as a right one did. That is a gate that stops measuring at the moment + Ambiguity, and the `context` field + ---------------------------------- + An ambiguous citation must not be answered with whatever the entry already + said. `if hint in matches: return hint` accepts *any* + occurrence of the text, so a hint that names the wrong one of three passes + exactly as a right one does. That is a gate that stops measuring at the moment someone interacts with it, and it fired twice in one week -- on include/morph/core/backend.hpp's two `emitMetric(registerCount)` arms and on include/morph/core/bridge.hpp's three `if (deadlineHandle && schedulerRef)` @@ -293,11 +290,9 @@ def resolve_allowlist_source_line(repo_root, path, hint, wanted, allowlist_path, `source` that has, and one that resolves to a *different* occurrence than the `line` hint is the defect itself. - There is no grandfathering list. PENDING_CONTEXT held the four (file, text) - pairs that were already ambiguous when this rule arrived, in two files - morph#701 was not allowed to touch; morph#711 migrated all five entries to a - `context` and deleted the constant. A migration list with nothing left in it - is an invitation to repopulate. + There is no grandfathering list, deliberately: every ambiguous entry carries + a `context`, and a migration list with nothing left in it is an invitation + to repopulate. """ source_file = os.path.join(repo_root, path) if not os.path.exists(source_file): @@ -341,7 +336,7 @@ def resolve_allowlist_source_line(repo_root, path, hint, wanted, allowlist_path, f"{path}:{hint} is allowlisted by a source line that appears " f"{len(matches)} times (lines {matches}), so the `line` hint alone does " f"not say which occurrence is meant -- and this gate would accept any of " - f"them (morph#701). Add a `context`: a verbatim source line within " + f"them. Add a `context`: a verbatim source line within " f"{CONTEXT_WINDOW} lines of the occurrence you mean, and beside no other." ) return None @@ -375,10 +370,9 @@ def resolve_allowlist_source_line(repo_root, path, hint, wanted, allowlist_path, def resolve_allowlist(repo_root, partial_lines, allowlist_path, failures): """Audit the allowlist in both directions; return the set it accounts for. - `source`, not `line`, is the key. A comment citing a bare line number is the - defect this repository has found three times (morph#349, morph#355, - morph#419), and an allowlist keyed that way would rot the same way while - still suppressing something. The line number is carried as a hint and + `source`, not `line`, is the key. A comment citing a bare line number is a + defect this repository has found three separate times, and an allowlist keyed + that way would rot the same way while still suppressing something. The line number is carried as a hint and reported back when it has moved. Both directions are audited because only one of them is obvious. An entry @@ -470,10 +464,9 @@ def check(lcov_path, repo_root, out=sys.stdout, allowlist_path=None, objects_pat allowlisted = resolve_allowlist(repo_root, partial_lines, allowlist_path, failures) # Vacuity, in both directions. A subsystem this gate names but the report - # does not contain is morph#403 happening again -- include/morph/net was - # absent from every uploaded report for exactly that reason, and a gate that - # reports "ok" over a missing subsystem is the silence that let it last - # through three occurrences. A subsystem the report contains but this gate + # does not contain has been dropped from the report -- include/morph/net was + # absent from every uploaded one for exactly that reason -- and a gate that + # reports "ok" over a missing subsystem is the silence that lets it last. A subsystem the report contains but this gate # does not name is the same defect mirrored: a new directory under # include/morph would be scored by nothing. profiled = read_profiled_binaries(objects_path) @@ -499,7 +492,7 @@ def check(lcov_path, repo_root, out=sys.stdout, allowlist_path=None, objects_pat if subsystems.get(name, [0])[0] == 0: failures.append( f"{name} contributes no branch records to {lcov_path}. Either it was " - f"dropped from the report -- which is morph#403's defect -- or it no " + f"dropped from the report, or it no " f"longer exists and this gate's table is stale. Both are errors." ) for name in subsystems: @@ -697,8 +690,8 @@ def with_partial_line(arms=(True, False)): note("ok: a subsystem below its branch floor is rejected") # 3. A subsystem missing from the report fails rather than being skipped. - # This is morph#403 in this gate's own terms: include/morph/net was absent - # from every uploaded report, and absence read as nothing to check. + # In this gate's own terms: include/morph/net was once absent from every + # uploaded report, and absence read as nothing to check. without_net = {k: v for k, v in _every_subsystem().items() if not k.startswith("include/morph/net/")} code, output = run(without_net) @@ -756,7 +749,7 @@ def with_partial_line(arms=(True, False)): # 8. An entry whose source text no longer exists fails, rather than # suppressing whatever now happens to sit at that line number. This is the - # rot morph#349, morph#355 and morph#419 are each an instance of. + # rot a line-number-keyed allowlist decays into. edited = "// header\nvoid f() {\n if (somethingElseEntirely()) {\n }\n}\n" code, output = run(with_partial_line(), allowlist=entry, sources={UNCOVERABLE_PATH: edited}) @@ -777,7 +770,7 @@ def with_partial_line(arms=(True, False)): else: fail("an allowlist entry whose line moved was not reported", output) - # 10. An entry with no reason fails. morph#404: a bare suppression is not a + # 10. An entry with no reason fails: a bare suppression is not a # disposition, and an allowlist that accepts one becomes a list of things # nobody has to justify. reasonless = [dict(entry[0], reason="")] @@ -788,12 +781,12 @@ def with_partial_line(arms=(True, False)): else: note("ok: an allowlist entry with no stated reason is rejected") - # ── The `context` disambiguator (morph#701) ──────────────────────────── - # Before morph#701 the resolver returned the hint whenever the hint was *a* - # match, so cases 11 and 12 below both passed -- including 12, which names - # the occurrence the entry's own reason excludes. These seven cases are the - # fix, and the pair 11/12 is what makes them non-vacuous: a change that only - # let the right answer through would still pass 13. + # ── The `context` disambiguator ──────────────────────────────────────── + # A resolver that returns the hint whenever the hint is *a* match passes + # cases 11 and 12 below alike -- including 12, which names the occurrence + # the entry's own reason excludes. The pair 11/12 is what makes these seven + # cases non-vacuous: a change that only let the right answer through would + # still pass 13. AMBIGUOUS_PATH = "include/morph/util/ambiguous.hpp" AMBIGUOUS_SOURCE = ( "// header\n" # 1 @@ -834,7 +827,7 @@ def run_ambiguous(allowlist): fail("an ambiguous citation with no `context` was accepted", output) # 12. The *wrong* occurrence, with no disambiguator -> refused identically. - # This is the case that passed before morph#701. + # This is the case a hint-accepting resolver lets through. code, output = run_ambiguous(ambiguous_entry(line=57)) if code != 0 and "does not say which occurrence is meant" in output: note("ok: the wrong duplicate is refused, not accepted for matching something") @@ -877,11 +870,11 @@ def run_ambiguous(allowlist): else: fail("a non-disambiguating `context` was accepted", output) - # ── The manifest-aware vacuity rule (morph#404 follow-up) ─────────────── + # ── The manifest-aware vacuity rule ──────────────────────────────────── # `cmake --preset clang-coverage` with nothing else profiles morph_tests # alone, and morph_tests compiles nothing under include/morph/net or - # include/morph/qt. Before these four cases the gate failed that configure - # while naming morph#403 -- a fixed defect -- as the cause. + # include/morph/qt. Without these four cases the gate fails that configure + # and blames a dropped subsystem for an option being off. partial_build = {name: recs for name, recs in _every_subsystem().items() if not name.startswith(("include/morph/net/", "include/morph/qt/"))} @@ -893,7 +886,7 @@ def run_ambiguous(allowlist): code, output = run(partial_build, profiled=["morph_tests", "morph_net_tests", "morph_qt_tests"]) - if code != 0 and "morph#403" in output: + if code != 0 and "contributes no branch records" in output: note("ok: a subsystem missing while its suite WAS profiled still fails") else: fail("a profiled-but-absent subsystem was accepted", output) @@ -905,7 +898,7 @@ def run_ambiguous(allowlist): fail("a stale OPTIONAL_SUBSYSTEMS mapping was accepted", output) code, output = run(partial_build) - if code != 0 and "morph#403" in output: + if code != 0 and "contributes no branch records" in output: note("ok: with no manifest the gate still enforces every subsystem") else: fail("omitting the manifest silently disabled the vacuity check", output) diff --git a/scripts/check_catch2_pin.sh b/scripts/check_catch2_pin.sh index de7dc9990..02fa69329 100755 --- a/scripts/check_catch2_pin.sh +++ b/scripts/check_catch2_pin.sh @@ -15,10 +15,10 @@ # workstation is not required to carry the runner's package, only to know # that it does not. # -# Why this gate exists, corrected by morph#777. The check the runner's Catch2 +# Why this gate exists. The check the runner's Catch2 # release decides is `bugprone-chained-comparison`, which every # examples/*/tests/.clang-tidy subtracts on the strength of that decision. -# Measured on 68a30bcc, clang-tidy 22.1.8, one TEST_CASE of twelve `REQUIRE`s, +# Measured with clang-tidy 22.1.8, one TEST_CASE of twelve `REQUIRE`s, # both releases reached identically (-isystem, i.e. as system headers, which is # how the runner and a workstation both reach theirs): # @@ -40,16 +40,15 @@ # the runner and on a workstation alike; one with an increment spelled in the # test source (a hand-written `if`, or a lambda inside a `REQUIRE` argument -- # macro arguments are spelled in the caller's file) is reported on both. So -# there is no version-driven asymmetry on that check to pin, and morph#666's -# "a local run exits 0 where CI exits 1" was morph#776: a `git diff -U0 HEAD` -# on a committed branch, which hands clang-tidy-diff.py nothing at all. -# -# morph#656's branch shipping a NOLINT reason that asserted a neighbouring -# TEST_CASE "scores under the threshold" while it scored 87 is still a real -# event; what it was evidence of was the empty diff, not the package. +# there is no version-driven asymmetry on that check to pin. "A local run exits +# 0 where CI exits 1" is almost always a `git diff -U0 HEAD` on a committed +# branch, which hands clang-tidy-diff.py nothing at all -- not a Catch2 +# difference. A NOLINT reason asserting a neighbouring TEST_CASE "scores under +# the threshold" while it scores 87 is evidence of that empty diff, not of the +# package. # # What this gate does and does not do, stated plainly, because the distinction -# is the whole point of the ticket: +# is the whole point: # # * It makes CI's own Catch2 a *decision* rather than an accident. `apt-get # install -y catch2` is unpinned; if the runner image's package moves, the @@ -62,7 +61,7 @@ # measurements are not the same one. Every other check agrees across # releases; a local run that disagrees with CI on one of those has a # different cause, and the first one to rule out is the diff base -# (morph#776 -- see CONTRIBUTING.md's "Formatting/linting" gate). +# (see CONTRIBUTING.md's "Formatting/linting" gate). # # Requires git and grep; compiles nothing. set -euo pipefail @@ -228,7 +227,7 @@ else printf ' comparison, which Catch2 NOLINTed out of its own REQUIRE macro in\n' >&2 printf ' 3.15.3. Against a release at or past that point it reports nothing\n' >&2 printf ' on any TEST_CASE, and the nine examples/*/tests/.clang-tidy files\n' >&2 - printf ' subtract it for findings you will never see here (morph#777).\n' >&2 + printf ' subtract it for findings you will never see here.\n' >&2 printf ' A green local run is not evidence for that check. It IS evidence\n' >&2 printf ' for every other one, including readability-function-cognitive-\n' >&2 printf ' complexity, which scores identically under both releases.\n' >&2 diff --git a/scripts/check_coverage_objects.sh b/scripts/check_coverage_objects.sh index 173a24264..c5ad995c1 100755 --- a/scripts/check_coverage_objects.sh +++ b/scripts/check_coverage_objects.sh @@ -4,9 +4,9 @@ # Fails if ctest runs a binary that llvm-cov is never handed, so that # everything the binary executed is measured as if it had never run. # -# Why this gate exists (morph#403). scripts/coverage.sh used to name the -# binaries it profiles by hand. It named three families while the tree built -# nine. morph_net_tests was instrumented, ran, and wrote profile data that +# Why this gate exists. A hand-written list of the binaries scripts/coverage.sh +# profiles falls behind the tree: it once named three families while the tree +# built nine. morph_net_tests was instrumented, ran, and wrote profile data that # llvm-profdata dutifully merged -- and then llvm-cov, which resolves counters # through a *binary*'s coverage mapping, was never given the binary, so the # whole of include/morph/net (955 lines, 42 of the library's 148 throw sites, @@ -17,12 +17,11 @@ # (the library's worst file) and include/morph/qt's 13 lines were measured with # their own suites absent. # -# That was the third occurrence. morph#141 (rungs 2-4 shipped without ever -# being added to coverage.sh, leaving ~15k lines outside the number) and -# morph#179 (the hand-copied rung list had drifted past ledger and lims) were -# the same defect in the same file. Both were fixed by deleting the copy and -# reading an authoritative list instead, and coverage.sh's own comment named -# the failure mode exactly: +# That is the third occurrence of one shape in this file. Rungs 2-4 shipped +# without ever being added to coverage.sh, leaving ~15k lines outside the +# number; and the hand-copied rung list drifted past ledger and lims. Both were +# fixed by deleting the copy and reading an authoritative list instead, and +# coverage.sh's own comment names the failure mode exactly: # # Nothing fails when a rung is forgotten -- the script runs, the report # uploads, and the figure is simply computed over a shrinking fraction. @@ -89,7 +88,7 @@ coverage_exclusion_reason() { echo "GAP: a real Catch2 suite (22 ctest cases driving include/morph's journal, offline queue, validation, transport-limit, versioning, connection-scope, observability and shutdown paths) whose target in examples/concepts/CMakeLists.txt has no apply_coverage() call at all, so it is not instrumented and contributes nothing. Fix: an if(AF_COVERAGE) apply_coverage(morph_concepts_tests) block there; the name already ends in _tests, so nothing else is needed" ;; morph_forms_demo) - echo "GAP: instrumented by examples/forms/CMakeLists.txt and driven by two ctest tests (forms_html_math, forms_repl_roundtrip), so it writes profile data that llvm-profdata merges and llvm-cov then drops -- morph#403's defect exactly. Fix: apply_coverage(morph_forms_demo TEST) there, TEST because a demo binary driven by tests is not named like a test" + echo "GAP: instrumented by examples/forms/CMakeLists.txt and driven by two ctest tests (forms_html_math, forms_repl_roundtrip), so it writes profile data that llvm-profdata merges and llvm-cov then drops -- this gate's whole subject. Fix: apply_coverage(morph_forms_demo TEST) there, TEST because a demo binary driven by tests is not named like a test" ;; morph_qt_tls_example) echo "GAP: run by the qt_tls_example_runs ctest test and never instrumented, so the pinned-certificate and insecure-verify paths it exercises through include/morph/qt score nothing. Fix: an if(AF_COVERAGE) apply_coverage(morph_qt_tls_example TEST) block in examples/qt_tls_client/CMakeLists.txt" @@ -276,8 +275,7 @@ if [ "${#unexplained[@]}" -gt 0 ]; then echo "Either instrument the target -- if(AF_COVERAGE) apply_coverage() in its" >&2 echo "CMakeLists.txt, with a name ending in _tests or with the TEST option -- or add it" >&2 echo "to coverage_exclusion_reason() in this script together with the reason it stays" >&2 - echo "out. See the header comment: the three previous occurrences of this defect" >&2 - echo "(morph#141, morph#179, morph#403) were all silent." >&2 + echo "out. See the header comment: all three previous occurrences were silent." >&2 exit 1 fi diff --git a/scripts/check_coverage_profiles.sh b/scripts/check_coverage_profiles.sh index d1088adcd..fce3127a4 100644 --- a/scripts/check_coverage_profiles.sh +++ b/scripts/check_coverage_profiles.sh @@ -7,9 +7,9 @@ # COVERAGE_OBJECTS manifest read already uses) rather than word-splitting a # single string, so a path containing a space is merged and deleted as one # unit instead of being silently split into fragments that `rm -f` no-ops on -# -- code review on the #430 fix found the space-joined form left exactly +# -- review of the discovery fix found the space-joined form left exactly # that gap, even though nothing in this repository's own paths triggers it -# today. This is the profile-discovery half of morph#430: naming it here, in +# today. This is the profile-discovery half of that fix: naming it here, in # one place, is what lets scripts/coverage.sh delete exactly this list once # it has merged them, so a later run's find can never inherit a stale file # this run already accounted for. diff --git a/scripts/check_coverage_roots.sh b/scripts/check_coverage_roots.sh index 1b151dd40..98c6e1840 100755 --- a/scripts/check_coverage_roots.sh +++ b/scripts/check_coverage_roots.sh @@ -4,7 +4,7 @@ # Fails if any file in the coverage mapping lies outside this checkout, so that # a report cannot be silently computed over a subset of the tree. # -# Why this gate exists (morph#426). A coverage build ran through the shared +# Why this gate exists. A coverage build run through the shared # compiler cache, the cache served objects compiled in a *different* worktree, # and clang had embedded that worktree's absolute source paths into their # coverage mappings. scripts/coverage.sh filters by *relative* path @@ -33,8 +33,8 @@ # having), the hazard returns the moment any cache is configured to key # path-independently, and the failure it produces is a *silence* -- a report # that shrinks while every command in the pipeline exits 0. That is the same -# failure mode scripts/coverage.sh's own comments record for morph#141 and -# morph#179, and it is the reason a derived list beat a hand-written one there. +# failure mode scripts/coverage.sh's own comments record for its rung and +# object lists, and it is the reason a derived list beats a hand-written one. # # The check is on the *unfiltered* mapping, and it has to be: the filtered # export contains only records that matched a relative SOURCES entry, so every @@ -55,10 +55,10 @@ # header-only template library instantiates the same function differently in # each binary and llvm-cov reconciles function records by (name, structural # hash). That is a property of measuring several binaries at once, not of where -# their sources came from, so a nonzero count here says nothing about morph#426 -# either way. What it does mean is that some records are discarded, which is -# why coverage.sh's figure is not simply the sum of its parts; morph#403's -# object list is what decides which binaries are in the sum. +# their sources came from, so a nonzero count here says nothing about a +# cross-checkout mapping either way. What it does mean is that some records are +# discarded, which is why coverage.sh's figure is not simply the sum of its +# parts; the coverage object list is what decides which binaries are in the sum. # # EXPORT_JSON (second argument) supplies `llvm-cov export -summary-only` output # from a file instead of running llvm-cov, which is what @@ -79,7 +79,7 @@ readonly profdata="${build_dir}/merged.profdata" readonly source_root="$(pwd -P)" # Third-party dependency sources are legitimately outside the checkout -# (morph#552). `cmake/DepCache.cmake` points FetchContent at a shared cache so a +# `cmake/DepCache.cmake` points FetchContent at a shared cache so a # CI run clones once instead of a dozen times, and those trees then sit under # the runner's home rather than under `build/*/_deps`, where they used to be # only because FetchContent happened to put them there. @@ -179,7 +179,7 @@ if not filenames: file=sys.stderr) print(" A gate that passes over an empty mapping is the defect it exists", file=sys.stderr) - print(" to find, committed by the detector (morph#426).", file=sys.stderr) + print(" to find, committed by the detector.", file=sys.stderr) raise SystemExit(1) foreign = sorted({f for f in filenames if not is_allowed(f)}) @@ -195,7 +195,7 @@ if foreign: print(" These records cannot match coverage.sh'"'"'s relative source filters, so", file=sys.stderr) print(" they are dropped and the report is computed over what is left.", file=sys.stderr) print(" Their most likely origin is a compiler cache serving objects built", file=sys.stderr) - print(" in another checkout (morph#426). Reconfigure the coverage build with", file=sys.stderr) + print(" in another checkout. Reconfigure the coverage build with", file=sys.stderr) print(" -DUSE_COMPILER_CACHE=OFF, or delete the build tree and rebuild.", file=sys.stderr) raise SystemExit(1) diff --git a/scripts/check_ctest_name_collisions.sh b/scripts/check_ctest_name_collisions.sh index 1b265a77c..d4d71283d 100755 --- a/scripts/check_ctest_name_collisions.sh +++ b/scripts/check_ctest_name_collisions.sh @@ -14,7 +14,7 @@ # of that name in the tree and CTest *appends* the labels, so the first # registration of a duplicated name accumulates the second's labels too. # In this repository that made `ctest -L ladder-` over-select: it ran -# another rung's binary alongside its own (morph#464 -- crm reported on 180 +# another rung's binary alongside its own (measured once as crm reporting on 180 # cases while owning 168). # * a failure line, `--output-junit` and CDash all identify a test by name, # so a duplicated name does not say which binary failed. @@ -116,7 +116,7 @@ if duplicates: sys.stderr.write( "\nGive the registrations distinct names: TEST_PREFIX/TEST_SUFFIX on the\n" "catch_discover_tests call (cmake/morph_add_rung.cmake does this per rung),\n" - "or rename the TEST_CASE. See this script\x27s header and morph#464.\n") + "or rename the TEST_CASE. See this script\x27s header.\n") raise SystemExit(1) print("ok: {} ctest test names under {} are unique".format(len(by_name), build_dir)) diff --git a/scripts/check_install_export.sh b/scripts/check_install_export.sh index 2a265701c..487fd6f79 100755 --- a/scripts/check_install_export.sh +++ b/scripts/check_install_export.sh @@ -5,7 +5,7 @@ # against `cmake --install`ed morph, via `find_package(morph CONFIG REQUIRED)` # and `target_link_libraries(... morph::morph)`. # -# Why this gate exists (morph#232): `cmake --install` on a morph build used to +# Why this gate exists: `cmake --install` on a morph build can # **exit 0** and install hundreds of Glaze headers plus a working # `glazeConfig.cmake` -- Glaze carries its own install/export rules and gets # them for free through `FetchContent` -- while installing zero morph headers @@ -152,7 +152,7 @@ if [ "$skip_header_set_verification" -eq 0 ]; then fi fi -# `cmake --install` exiting 0 is exactly what it did before morph#232, so its +# `cmake --install` exiting 0 is exactly what it does when it installs nothing, so its # exit status is worth nothing on its own. It is checked anyway -- a *failing* # install is still a failure -- and then the prefix is inspected. run_step "cmake --install failed outright" \ @@ -166,7 +166,7 @@ for required in \ "include/morph/util/rational.hpp" \ "include/morph/core/bridge.hpp"; do if [ ! -f "${prefix}/${required}" ]; then - fail "cmake --install exited 0 but installed no ${required} -- this is the morph#232 shape" + fail "cmake --install exited 0 but installed no ${required} -- it reported success having installed nothing" fi done @@ -196,7 +196,7 @@ note "the prefix contains morph's headers and package config" # ── 4. Build a consumer against the prefix ────────────────────────────────── # # The TU includes *every installed non-detail header*, generated from the prefix -# rather than hand-listed. A hand-list is what let morph#540 ship: it named +# rather than hand-listed. A hand-list ships stale: one named # `` and not `app.hpp`/`flows.hpp`/`sections.hpp`, all # three of which include `forms/detail/session_common.hpp` -- a header no # FILE_SET installed. `cmake --install` exited 0, this check stayed green, and diff --git a/scripts/check_sanitizer_instrumentation.sh b/scripts/check_sanitizer_instrumentation.sh index 1dce6a687..8114044d8 100755 --- a/scripts/check_sanitizer_instrumentation.sh +++ b/scripts/check_sanitizer_instrumentation.sh @@ -5,7 +5,7 @@ # # A sanitizer job whose binaries are not actually instrumented is worse than no # job: it runs the whole suite, reports success, and every reader treats that as -# evidence the suite was checked (morph#542). +# evidence the suite was checked. # # CI already asserted this, but only for the ladder's own binaries and only for # `__asan_`. Everything else was on trust, and the trust was misplaced: measured @@ -22,7 +22,7 @@ # vacuous on the ubsan and tsan legs, which is the same "control that measures # nothing" this check exists to prevent. # -# ── The two questions, and why --binary exists (morph#675) ─────────────────── +# ── The two questions, and why --binary exists ─────────────────────────────── # # The sweep above answers "did this check examine a representative set?", and # its floor (below) is what makes that answer mean something. A developer who @@ -80,7 +80,7 @@ count_symbols() { nm -C "$1" 2>/dev/null | grep -c -- "${symbol}" || true } -# ── Narrow mode: one named file, no floor, never in CI (morph#675) ─────────── +# ── Narrow mode: one named file, no floor, never in CI ─────────────────────── # # The refusal below is what keeps the floor intact. Everything after it is a # statement about a single file, so there is no set for a floor to be about -- @@ -130,7 +130,7 @@ allowlist=() # # `2>/dev/null` made the two ways this list can come back empty # indistinguishable: "ctest enumerated the tree and it registers no tests" and -# "ctest itself failed before printing any JSON". morph#690 was the second one, +# "ctest itself failed before printing any JSON". The second is the one that # and it cost three CI runs and two local sessions to name, because the # sentence that named it was being discarded one pipe away from the error # message. What ctest actually wrote, reproduced locally against a diff --git a/scripts/check_tidy_suppression_scope.sh b/scripts/check_tidy_suppression_scope.sh index f1029d019..9eafd0fe6 100755 --- a/scripts/check_tidy_suppression_scope.sh +++ b/scripts/check_tidy_suppression_scope.sh @@ -3,7 +3,7 @@ # # Keeps tests/.clang-tidy's record of its own reach true. # -# Why this gate exists (morph#632): clang-tidy resolves its configuration from +# Why this gate exists: clang-tidy resolves its configuration from # the path of the translation unit it is analysing, not from the path of the # file a diagnostic lands in. tests/.clang-tidy's thirteen suppressions are # each argued as *test idiom* -- Catch2's REQUIRE expansion, a raw-syscall @@ -26,8 +26,9 @@ # include/morph/ is compiled from a TU under tests/ and from a TU that is # not, using this repository's *real* .clang-tidy files, and the finding # must be absent from the first and present from the second. Behavioural -# rather than a grep for the mechanism, for the reason morph#298 -# established -- and this direction matters twice over: if clang-tidy ever +# rather than a grep for the mechanism: a grep asserts that the words are +# there, not that the suppression reaches where the note says it does -- +# and this direction matters twice over: if clang-tidy ever # resolves configuration per diagnostic file, the note goes red rather # than quietly stale. # @@ -108,7 +109,7 @@ fi if [ "$disabled" != "$recorded" ]; then fail "tests/.clang-tidy's \`header-reach:\` list does not match what its \`Checks:\` key subtracts. Every suppression there is also off for every - include/morph/** header reached from a TU under tests/ (morph#632), and the + include/morph/** header reached from a TU under tests/, and the list is the only place a reader is told which ones. Differences: $(diff <(printf '%s\n' "$disabled") <(printf '%s\n' "$recorded") \ | sed 's/^< / only in Checks: /; s/^> / only in header-reach: /' \ @@ -181,7 +182,7 @@ $(printf '%s' "$from_src" | sed 's/^/ /')" fail "${check} IS now reported against include/morph/probe_scope.hpp from a TU under tests/, where tests/.clang-tidy subtracts it. clang-tidy appears to resolve configuration per diagnostic file rather than per translation unit, - which is the opposite of what morph#632 measured and of what the note in + which is the opposite of what was measured and of what the note in tests/.clang-tidy tells readers. Re-measure the reach, correct or delete that note, and revisit any header gate built on the old behaviour." else diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 7d8e53b73..adf7fdc9f 100644 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -29,10 +29,10 @@ MANIFEST="$OUT/coverage_objects.txt" # lines, 42 of the library's 148 throw sites, eight test files driving it -- # contributed zero files to the uploaded report, and both # sqlite_offline_queue.hpp's 57.04% and include/morph/qt's 13 lines were -# measured with their own suites absent (morph#403). It was the third time: -# morph#141 (rungs 2-4 never added) and morph#179 (the rung list had drifted -# past ledger and lims) were the same defect in the same file, and both were -# fixed by deleting the copy and reading an authoritative list instead. +# measured with their own suites absent. That was the third time: rungs 2-4 +# were never added, and the rung list drifted past ledger and lims -- the same +# defect in the same file, both times fixed by deleting the copy and reading an +# authoritative list instead. # # So the list is no longer here. cmake/compiler_options.cmake's # apply_coverage() registers every instrumented test binary as it instruments @@ -50,7 +50,7 @@ MANIFEST="$OUT/coverage_objects.txt" # -- The profile set this run merges: bounded, not discovered --------------- # # `find "$OUT" -name '*.profraw'` used to be unbounded and had no cleanup of -# its own (morph#430). `LLVM_PROFILE_FILE`'s `%p` (process id) template means +# its own. `LLVM_PROFILE_FILE`'s `%p` (process id) template means # a stale file is never overwritten by a later run -- it just sits there and # the next `find` picks it up alongside the current run's output, with no # upper bound on how old "alongside" can be. 2,445 such files were found @@ -59,7 +59,7 @@ MANIFEST="$OUT/coverage_objects.txt" # name pattern. A stale line's counts joining the current report is wrong in # the flattering direction -- a line covered by a since-deleted test still # reads as covered -- and it is the same failure shape this script's own -# comments already document twice over for morph#141 and morph#179: the +# comments already document twice over for the rung and object lists: the # script runs, the report uploads, and the figure is computed over the wrong # input. # @@ -87,7 +87,7 @@ MANIFEST="$OUT/coverage_objects.txt" # to merge and to delete), and a space-joined string word-splits a path # containing a space into fragments -- `rm -f` then silently no-ops on the # fragments (its whole point is to not fail on a missing file) and leaves the -# real .profraw undeleted, which is morph#430's own staleness shape +# real .profraw undeleted, which is the same staleness shape # reappearing through the one script meant to close it. Nothing in this # repository's own paths triggers this today, but the fix costs nothing and # matches COVERAGE_OBJECTS' own array-of-paths shape below. @@ -162,7 +162,7 @@ coverage_object_built() { # hand-written form silently rotted: rungs 2, 3 and 4 shipped without ever # being added here, leaving ~15k lines of models, presenters and QML adapters # outside the coverage number entirely while the percentage still looked -# healthy (morph#141). Nothing fails when a rung is forgotten -- the script +# healthy. Nothing fails when a rung is forgotten -- the script # runs, the report uploads, and the figure is simply computed over a # shrinking fraction of the ladder. A loop over the known rungs cannot be # forgotten in the same way; a new rung needs its name added here and @@ -177,7 +177,7 @@ coverage_object_built() { # files the report did not contain. A component that matches nothing does not # fail; it silently reports nothing, which is the same shape of defect this # list's own comment above warns about, and the same one that left CI's ladder -# path filter a rung behind (morph#179). Reading the list removes the copy. +# path filter a rung behind. Reading the list removes the copy. # # A rung that was not configured in this build contributes nothing, exactly as # before, via the coverage_object_built() guard. @@ -248,7 +248,7 @@ done # bar means to hold to that standard — only the real testkit/GUI code is. # The library's own two TUs are not in that directory at all any more: they # live in examples/common/testkit_src/ so that testkit/.clang-tidy's Catch2 -# suppression cannot reach them (morph#652). They stay measured -- SOURCES +# suppression cannot reach them. They stay measured -- SOURCES # names examples/common, and this regex matches only testkit/test_*.cpp. IGNORE_REGEX='.*/testkit/test_[^/]+\.cpp$' @@ -262,7 +262,7 @@ ${LLVM_PROFDATA} merge -sparse "${PROFILES[@]}" -o "$MERGED" # whatever a rerun of `ctest` also wrote. That second merge would not be # additive counting error (llvm-profdata's counts are absolute per process # execution, not deltas), but it is the same "profile set for a run is -# discovered, not defined" shape morph#430 exists to remove, just triggered +# discovered, not defined" shape the manifest exists to remove, just triggered # by a retry instead of an old worktree. See PROFILES' own assignment above # for the full account. rm -f "${PROFILES[@]}" @@ -273,7 +273,7 @@ rm -f "${PROFILES[@]}" # this tree by construction. A shared compiler cache can serve objects built in # another worktree, and their coverage mappings name that worktree -- 246 of # 688 records in the run that found it, taking the whole of crm's src models -# out of the report while its tests ran and passed (morph#426). The cause is +# out of the report while its tests ran and passed. The cause is # removed at configure time (cmake/compiler_options.cmake defaults # USE_COMPILER_CACHE to OFF under AF_COVERAGE); this is the check that the # removal held, because the failure it guards against reports nothing. @@ -320,7 +320,7 @@ ${LLVM_COV} export "$PRIMARY_OBJECT" \ python3 scripts/aggregate_lcov_branches.py \ "$OUT/coverage.lcov.raw" "$OUT/coverage.json" "$OUT/coverage.lcov" -# The branch half of the number, which until morph#404 was produced, preserved, +# The branch half of the number, which is easily produced, preserved, # uploaded and scored by nothing: every status and every component in # codecov.yml measures lines, and Codecov has no branch target to set. A line # is covered the moment control reaches it, whatever the condition on it diff --git a/scripts/ladder_rungs.sh b/scripts/ladder_rungs.sh index 1fd78d1e2..813b552f4 100644 --- a/scripts/ladder_rungs.sh +++ b/scripts/ladder_rungs.sh @@ -15,7 +15,7 @@ # at kanban (rung 4), so a change confined to examples/ledger/ or # examples/lims/ matched nothing and skipped both ladder jobs — silently, since # a filter that matches nothing reports success just as loudly as one that -# matches everything (morph#179). Deriving the regex here means it cannot fall +# matches everything. Deriving the regex here means it cannot fall # behind the list again. # # Consumers that genuinely cannot call this (GitHub evaluates a workflow's @@ -46,12 +46,12 @@ readonly rung_file="${repo_root}/examples/rungs.txt" # the ladder's normative rules # scripts/scenario/ the scenario corpus and its driver, which # `ladder-tests` now *runs* against the servers it has -# just built (morph#462). Without this entry a pull +# just built. Without this entry a pull # request that touched only the corpus would match # nothing and skip the job -- so the one gate that can # catch a broken scenario would be absent from exactly # the changes most able to break one. That is -# morph#179's defect in a second location, and +# the drifted-hand-copy defect in a second location, and # drift-guard.yml's scenario-coverage job carries no # path filter at all for the same reason. # Not `readonly`: this script has to stay runnable under the bash 3.2 that diff --git a/scripts/scenario/README.md b/scripts/scenario/README.md index 6ad55f28b..ae691c7b8 100644 --- a/scripts/scenario/README.md +++ b/scripts/scenario/README.md @@ -89,8 +89,8 @@ Ledger is seeded with two `ledgers` rows before its scenarios run — the only rung that gets any. They are fixture books for the fourteen ledger files written against the fixed ids `1` and `2`, one of which (`two-books-are-isolated`) needs two books to exist before its first step. -They are no longer a statement that a book cannot be created over the wire: -`CreateLedger` (morph#361) does that, and +They are not a statement that a book cannot be created over the wire: +`CreateLedger` does that, and `ledger/bootstrap-a-book-over-the-wire.scenario` names no seeded id at all, so it would pass against a database the driver never touched. Every other rung creates its own root entity over the wire and is seeded with nothing. @@ -134,12 +134,11 @@ is the opposite arrangement from `broken-on-purpose.scenario`, which fails today by design. `bank/an-owner-named-outright-is-checked-against-the-session.scenario` is what -that arrangement looks like after the fix lands. It was -[morph#471](https://github.com/LASTRADA-Software/morph/issues/471)'s inventory, -written entirely `expect ok` because `bank::resolveOwner()` preferred a -caller-supplied owner name over the session principal and ten actions therefore -served a signed-in customer another customer's data. Flipping those assertions -to `expect err` was that issue's regression test, and the file now pins the +that arrangement looks like when the ownership gate holds. Were +`bank::resolveOwner()` to prefer a caller-supplied owner name over the session +principal, ten actions would serve a signed-in customer another customer's +data, and every assertion in that file would have to read `expect ok`. It reads +`expect err` instead, and pins the enforcement in the same shape it once pinned the defect. The rung a scenario belongs to is its parent directory name — that is how @@ -268,10 +267,10 @@ already authenticated. The credentials may equally be written on the `client` line that opens the connection — `client books model=LedgerModel principal=$who token=$token` — and -mean the same thing as the two-line `client` then `session` form. Until -morph#360 they did not: `client` read its options raw, so `$token` went to the -server as six literal characters and the run failed several steps later with a -bare `unauthorized` that named neither the step nor the cause. +mean the same thing as the two-line `client` then `session` form. That equality +is load-bearing: were `client` to read its options raw, `$token` would go to +the server as six literal characters and the run would fail several steps later +with a bare `unauthorized` naming neither the step nor the cause. ## Proving a scenario's assertions are real @@ -432,23 +431,18 @@ python3 scripts/scenario/a gate removed on 2026-09-23 - **No `sleep`, and no wall-clock waits.** Every assertion is on a reply to a request this file sent. A scenario cannot become a source of flaky timing the - way [morph#147](https://github.com/LASTRADA-Software/morph/issues/147) once - did, because there is nothing to wait *on*. + way a polling loop does, because there is nothing to wait *on*. - **No server lifecycle.** It drives a server someone else started. Starting, waiting for the port and tearing down belong to whatever runs it. -- **No schema validation of inputs.** [morph#171](https://github.com/LASTRADA-Software/morph/issues/171) - proposed checking a scenario's inputs against the server's served JSON Schema - before sending. The runner does not do that, and this entry used to say it - *could* not, because "morph does not serve schemas over the wire … no - envelope `kind` exposes them to a remote client". That is not true: the - `schemas` kind serves exactly that document, and - `scenarios/pastebin/wire-kinds-and-typeid-refusals.scenario` reads +- **No schema validation of inputs.** The runner does not check a scenario's + inputs against the server's served JSON Schema before sending, and the reason + is *not* that it could not: the `schemas` kind serves exactly that document, + and `scenarios/pastebin/wire-kinds-and-typeid-refusals.scenario` reads `PasteModel`'s out of a live server, `required` array, declared bounds and - all. What remains true is the narrower statement: this runner sends what the + all. The narrower statement is the true one: this runner sends what the file says without consulting it, which is also what makes deliberately malformed payloads expressible. Validating against the served schema is - therefore now *possible* and merely not done — see - [morph#234](https://github.com/LASTRADA-Software/morph/issues/234). + therefore *possible* and merely not done. - **It does not replace the C++ tests.** Model behaviour is tested in-process and stays there. This covers the seam those tests assume away. @@ -456,7 +450,6 @@ python3 scripts/scenario/a gate removed on 2026-09-23 `docs/spec/core/wire.md`'s envelope tables omit the `primary` and `shared` fields and the `attach`, `assign` and `instances` kinds that -`include/morph/core/wire.hpp` and `RemoteServer::dispatchMessage` actually carry -— see [morph#233](https://github.com/LASTRADA-Software/morph/issues/233). A -client written from the spec alone gets an incomplete envelope; this one sends -the full field set taken from the header. +`include/morph/core/wire.hpp` and `RemoteServer::dispatchMessage` actually +carry. A client written from the spec alone gets an incomplete envelope; this +one sends the full field set taken from the header. diff --git a/scripts/scenario/coverage_allowlist.json b/scripts/scenario/coverage_allowlist.json index 20e9b0d3c..62a54db38 100644 --- a/scripts/scenario/coverage_allowlist.json +++ b/scripts/scenario/coverage_allowlist.json @@ -1,14 +1,14 @@ { "kinds": {}, "messages": { - "connection closed": "Emitted from the shared-instance attach path (remote.hpp, noteScopeAttachLocked) only when a connection's scope closes concurrently with an attach in flight on it. That is a genuine race; a scenario cannot drive it deterministically, and forcing it with a sleep would reintroduce the wall-clock flakiness this runner exists to avoid (morph#147). See the design spec, 'The measurable universe'.", + "connection closed": "Emitted from the shared-instance attach path (remote.hpp, noteScopeAttachLocked) only when a connection's scope closes concurrently with an attach in flight on it. That is a genuine race; a scenario cannot drive it deterministically, and forcing it with a sleep would reintroduce the wall-clock flakiness this runner exists to avoid. See the design spec, 'The measurable universe'.", "handleInline does not support execute (reply is asynchronous)": "handleInline is a synchronous, in-process C++ API used only by morph's own C++ unit tests; it is not an envelope kind or a path any WebSocket client can reach, so no scenario can ever assert this refusal. It was previously excluded by accident -- the message regexes missed its multi-line call site -- and is now excluded on purpose.", "server busy": "Emitted only when LimitPolicy::maxInFlightExecutes is non-zero, and no ladder rung sets it: bookmarks, polls, kanban and ledger each construct a LimitPolicy in their own app.cpp, set maxLiveModels = 256 and leave every other field at its 0 (\"unbounded\") default, and pastebin installs no policy at all. The early-shed branch in remote.hpp is therefore unreachable against any server a scenario can drive. A rung that ever sets it retires this entry, and scenarios/bookmarks/the-live-model-cap-is-reached.scenario is what asserting the one limit those rungs *do* install looks like.", - "timeout": "Emitted only when LimitPolicy::executeTimeout is non-zero, which no ladder rung sets -- see the 'server busy' entry for the same survey of every rung's app.cpp. The timeout is scheduled per execute against the installed policy, so with the field at 0 no timer is ever armed and the refusal cannot be produced. Driving it would also require an action slow enough to outlast the timeout, which is a wall-clock dependence this runner forbids by design (morph#147).", + "timeout": "Emitted only when LimitPolicy::executeTimeout is non-zero, which no ladder rung sets -- see the 'server busy' entry for the same survey of every rung's app.cpp. The timeout is scheduled per execute against the installed policy, so with the field at 0 no timer is ever armed and the refusal cannot be produced. Driving it would also require an action slow enough to outlast the timeout, which is a wall-clock dependence this runner forbids by design.", "payload missing required field(s): ": "Requires PayloadCompleteness::RequireDeclaredFields, which no ladder rung installs and which remote.hpp documents as deliberately not the default -- turning it on rejects payloads a pre-existing client sends today, a non-additive wire change that the action-evolution policy says needs a kProtocolVersion bump. With the default Lenient setting the check never runs, so the diagnostic is unreachable regardless of what a scenario sends.", - "server shutting down": "Emitted only while RemoteServer is draining: the check reads the shutdown flag that RemoteServer::shutdown() sets, so a scenario would have to land a request inside the window between that call and the process exiting. The driver tears each server down after the last reply of the last scenario, and there is no wire action that asks a server to begin shutting down, so nothing a scenario can send opens that window. Forcing the overlap would mean racing a teardown against a request -- a wall-clock dependence this runner forbids (morph#147)." + "server shutting down": "Emitted only while RemoteServer is draining: the check reads the shutdown flag that RemoteServer::shutdown() sets, so a scenario would have to land a request inside the window between that call and the process exiting. The driver tears each server down after the last reply of the last scenario, and there is no wire action that asks a server to begin shutting down, so nothing a scenario can send opens that window. Forcing the overlap would mean racing a teardown against a request -- a wall-clock dependence this runner forbids." }, "actions": { - "ledger/RunReportJob": "LedgerModel::execute(const RunReportJob&) refuses any principal but kReportRunnerPrincipal (src/models/ledger_model.cpp:1205-1206, message \"RunReportJob: only the report runner may run a report job\"); it is dispatched by the app's own report runner, never by a user client. A scenario can submit a report and assert Pending, and can assert this refusal when a user client tries to run the job, but cannot drive it to Done -- that needs the server's own runner to tick, and waiting on it would mean a sleep, which this runner forbids by design. See morph#362." + "ledger/RunReportJob": "LedgerModel::execute(const RunReportJob&) refuses any principal but kReportRunnerPrincipal (src/models/ledger_model.cpp:1205-1206, message \"RunReportJob: only the report runner may run a report job\"); it is dispatched by the app's own report runner, never by a user client. A scenario can submit a report and assert Pending, and can assert this refusal when a user client tries to run the job, but cannot drive it to Done -- that needs the server's own runner to tick, and waiting on it would mean a sleep, which this runner forbids by design." } } diff --git a/scripts/scenario/morph_scenario.py b/scripts/scenario/morph_scenario.py index 0a2c13698..440528387 100755 --- a/scripts/scenario/morph_scenario.py +++ b/scripts/scenario/morph_scenario.py @@ -222,8 +222,8 @@ def close(self) -> None: #: Every field `morph::wire::Envelope` reflects, with its default. #: #: The driver sends the **full** set on every request. `decode` ignores unknown -#: keys and defaults absent ones, so this is valid — but note that it is no -#: longer what morph's own `encode` does: since morph#524 `encode` omits every +#: keys and defaults absent ones, so this is valid — but note that it is not +#: what morph's own `encode` does: `encode` omits every #: member holding its default, writing only `kind`, `callId` and whatever the #: kind actually populates. Sending all thirteen is a deliberate divergence #: kept for two reasons: @@ -339,10 +339,9 @@ def rpc(self, envelope: dict[str, Any]) -> Reply: That carve-out keys on the *value* `0`, not on the key being missing, and that is deliberate: `callId` is a correlation field whose zero is a routing sentinel, so `encode` never omits it even though it omits every - other defaulted member (morph#524). If that ever changes, `got` becomes + other defaulted member. If that ever changes, `got` becomes `None` here, the `got == 0` test stops matching, and every step of that - scenario fails as a transport error rather than an assertion — which is - exactly what it did while `callId` was briefly elided. + scenario fails as a transport error rather than an assertion. """ self.socket.send_text(json.dumps(envelope)) want = envelope["callId"] @@ -481,7 +480,7 @@ def parse_value(token: str, captures: dict[str, Any]) -> Any: # The rest name the connection itself, and are static in every shipped # scenario. They are not expanded — a capture in one is refused by name rather -# than sent literally (morph#360). +# than sent literally. STATIC_CLIENT_OPTIONS = ("url", "model", "protocol") @@ -489,10 +488,10 @@ def resolve_client_options(options: dict[str, str], captures: dict[str, Any]) -> """Expands `$capture` references in a `client` step's options. `session` runs its values through `parse_value`, so `session token=$token` - installs the captured token; `client` read its own options raw, so - `client books token=$token` sent the six literal characters and the run - failed several steps later with a bare `unauthorized` (morph#360). This is - the one place both spellings now agree. + installs the captured token. Were `client` to read its own options raw, + `client books token=$token` would send the six literal characters and the + run would fail several steps later with a bare `unauthorized`. This is + the one place both spellings agree. Only the credential options are expanded. A capture in `url`, `model` or `protocol` is refused here, naming the option — those are static in every diff --git a/scripts/scenario/mutate_scenario.py b/scripts/scenario/mutate_scenario.py index f5ec99e2f..c64354c20 100755 --- a/scripts/scenario/mutate_scenario.py +++ b/scripts/scenario/mutate_scenario.py @@ -35,7 +35,7 @@ def mutants(lines: list[str]) -> list[tuple[int, str, str]]: out.append((index, line.replace("expect err", "expect ok", 1), "kind err->ok")) # Both directions of every comparison `parse_expect` accepts. An # operator that is only ever a *destination* here is one no assertion - # written with it is ever mutated at (morph#383): its lone mutant is + # written with it is ever mutated at: its lone mutant is # the kind flip above, which the reply's own kind catches while the # comparison itself measures nothing. Each pair's source substring is # absent from every other pair's, so at most one fires per line and a diff --git a/scripts/scenario/run_scenarios.py b/scripts/scenario/run_scenarios.py index f9e421702..35c4d5698 100755 --- a/scripts/scenario/run_scenarios.py +++ b/scripts/scenario/run_scenarios.py @@ -114,11 +114,11 @@ def environment(self, db_path: pathlib.Path) -> dict[str, str]: def seed(self, db_path: pathlib.Path) -> None: """Inserts the fixture rows a rung's scenarios name by a fixed id. - Ledger is the only rung that gets any, and what they are for changed - with morph#361. They used to be unavoidable: `ledgers` rows were - created by no registered action, so `OpenAccount ledgerId=1` against a - fresh database was refused with "no such ledger" and every ledger - journey was unreachable over the wire. `CreateLedger` closed that, and + Ledger is the only rung that gets any, and they are a convenience + rather than a necessity: with no action that creates a `ledgers` row, + `OpenAccount ledgerId=1` against a fresh database is refused with "no + such ledger" and every ledger journey is unreachable over the wire. + `CreateLedger` is what removes that dependence, and `scenarios/ledger/bootstrap-a-book-over-the-wire.scenario` is the file that proves it -- it names no seeded id at all and would pass against a database this method never touched. @@ -196,8 +196,8 @@ def seed(self, db_path: pathlib.Path) -> None: db_var="LEDGER_DB", token_secret_var="LEDGER_TOKEN_SECRET", # Fixture books for the fourteen ledger files written against the - # fixed ids 1 and 2 -- see `seed`'s doc comment for why they stay now - # that `CreateLedger` exists (morph#361). + # fixed ids 1 and 2 -- see `seed`'s doc comment for why they stay even + # though `CreateLedger` exists. seed_sql=( "INSERT OR IGNORE INTO ledgers (id, name) VALUES (1, 'Scenario book')", "INSERT OR IGNORE INTO ledgers (id, name) VALUES (2, 'Second book')", diff --git a/scripts/scenario/scenarios/bank/a-card-through-its-whole-lifecycle.scenario b/scripts/scenario/scenarios/bank/a-card-through-its-whole-lifecycle.scenario index 5f3e6ac5d..89f7f36d5 100644 --- a/scripts/scenario/scenarios/bank/a-card-through-its-whole-lifecycle.scenario +++ b/scripts/scenario/scenarios/bank/a-card-through-its-whole-lifecycle.scenario @@ -12,8 +12,8 @@ # The card's PAN is also worth noting for what is *absent*: bank stores only the # last four digits (`panLast4`) and there is no action anywhere on this model # that returns a full number. That is a property of the schema, not a masking -# step some client remembered to apply — see morph#86, where the proposed -# `QQmlPropertyMap` projection was rejected partly for undoing it. +# step some client remembered to apply. A client-side projection that exposed +# the full number would undo that property, which is one reason none exists. model AuthModel client front diff --git a/scripts/scenario/scenarios/bank/an-owner-named-outright-is-checked-against-the-session.scenario b/scripts/scenario/scenarios/bank/an-owner-named-outright-is-checked-against-the-session.scenario index d27dca695..c28510037 100644 --- a/scripts/scenario/scenarios/bank/an-owner-named-outright-is-checked-against-the-session.scenario +++ b/scripts/scenario/scenarios/bank/an-owner-named-outright-is-checked-against-the-session.scenario @@ -23,14 +23,12 @@ # `ListBudgets`, `ListNotifications`, `GenerateStatement`, `OpenAccount` # and `MarkAllRead`. Every one of them is exercised below. # -# This file used to assert the opposite. Until morph#471 was fixed -# (https://github.com/LASTRADA-Software/morph/issues/471) `resolveOwner` -# returned whatever string the caller put in the `owner` field and only fell -# back to the session principal when that field was empty — nothing compared -# the two — so every refusal below was an `expect ok` recording a -# confused-deputy read of another customer's data. Flipping those assertions is -# that issue's regression test; the shape of the file is unchanged so the two -# revisions read against each other. +# Every assertion below inverts if the comparison goes: a `resolveOwner` that +# returns whatever string the caller put in the `owner` field and only falls +# back to the session principal when that field is empty — comparing the two +# nowhere — turns every refusal below into an `expect ok` recording a +# confused-deputy read of another customer's data. That is what makes this file +# a test of the comparison rather than of the actions it covers. # # The field is **verified, not ignored**. Naming yourself still works and is # asserted below, twice: `owner` is `CustomerModel`'s bridge routing key @@ -135,8 +133,8 @@ expect err message == "owner does not match the session principal" # `resolveOwner` never sees it and the comparison above cannot be the thing # that refuses it. It goes through `db::loadOwned` instead, the same guard the # id-addressed actions use, which is why its message is the other shape. It -# lives on `BudgetModel`. Before morph#471 it consulted no owner whatsoever and -# reported on any account in the database. +# lives on `BudgetModel`. Without that guard it consults no owner whatsoever +# and reports on any account in the database. use s-plan do SpendingByKind accountId=$qAcct sinceMs=0 expect err message == "account belongs to a different owner" diff --git a/scripts/scenario/scenarios/bank/banking-without-a-session-is-refused.scenario b/scripts/scenario/scenarios/bank/banking-without-a-session-is-refused.scenario index 4a4ebd976..aa582a220 100644 --- a/scripts/scenario/scenarios/bank/banking-without-a-session-is-refused.scenario +++ b/scripts/scenario/scenarios/bank/banking-without-a-session-is-refused.scenario @@ -26,8 +26,8 @@ # * "owner does not match the session principal" — the action carries an # `owner` field and the caller *filled it in*. `resolveOwner` compares it # with the session principal and refuses, and an empty principal matches -# nothing. This is the shape morph#471 added; before it, naming an owner -# was how an anonymous caller got served that owner's data. +# nothing. Without that comparison, naming an owner is how an anonymous +# caller gets served that owner's data. # * "... belongs to a different owner" — the action is addressed by row id, # so there is no `owner` field to check, and the empty principal simply # failed to match the row's real owner further down. @@ -154,11 +154,11 @@ do SpendingByKind accountId=$acct sinceMs=0 expect err message == "account belongs to a different owner" # ── Refused by the owner comparison instead ──────────────────────────────── -# The shape morph#471 added. Everything above left `owner` empty, which is the -# only case the old `resolveOwner` consulted the session for; filling it in was -# how an anonymous caller reached a real customer's data, because the field was -# taken verbatim and compared with nothing. It is now compared with the session -# principal, and an empty principal matches no name at all — so the anonymous +# Everything above leaves `owner` empty, which a `resolveOwner` that consults +# the session only for the empty case would still refuse. Filling the field in +# is the case that separates the two: taken verbatim and compared with nothing, +# it is how an anonymous caller reaches a real customer's data. It is compared +# with the session principal, and an empty principal matches no name at all — so the anonymous # client is refused before any `users` row is looked up. use anon-vault do ListAccounts owner=$who diff --git a/scripts/scenario/scenarios/bookmarks/sign-in-create-tag-and-read-back.scenario b/scripts/scenario/scenarios/bookmarks/sign-in-create-tag-and-read-back.scenario index 435cbefc6..b801a4d92 100644 --- a/scripts/scenario/scenarios/bookmarks/sign-in-create-tag-and-read-back.scenario +++ b/scripts/scenario/scenarios/bookmarks/sign-in-create-tag-and-read-back.scenario @@ -22,7 +22,7 @@ # * AuthModel is the one model an unauthenticated caller may reach. The # token it mints is real and server-signed, and it has to be installed # with a `session` step -- `client`'s own principal=/token= options take -# literal text and are not capture-expanded (morph#360). +# literal text and are not capture-expanded. # # Re-runnable: every bookmark here is created by this file and read back by # the id its own create returned, so nothing depends on the database being diff --git a/scripts/scenario/scenarios/kanban/a-board-must-be-opened-before-it-answers.scenario b/scripts/scenario/scenarios/kanban/a-board-must-be-opened-before-it-answers.scenario index 6b86d0bf0..2fcd3064e 100644 --- a/scripts/scenario/scenarios/kanban/a-board-must-be-opened-before-it-answers.scenario +++ b/scripts/scenario/scenarios/kanban/a-board-must-be-opened-before-it-answers.scenario @@ -14,12 +14,12 @@ # `what()` of the `std::invalid_argument` `std::stoull("")` throws -- even # though all fifteen `execute()` overloads already opened with a # `_projectIdStr.has_value()` guard carrying the correct message. The optional -# was *engaged with an empty string* on an unattached handler, so `has_value()` -# was true, every guard fell through to `requireRole`, and `std::stoull("")` -# threw. `_projectIdStr` was written by `attachActionLog` as well as by +# is *engaged with an empty string* on an unattached handler, `has_value()` is +# true, every guard falls through to `requireRole`, and `std::stoull("")` +# throws. `_projectIdStr` is written by `attachActionLog` as well as by # `OpenBoard`, and `ModelFactory::create` calls it with an empty `entityKey` on -# every newly constructed holder. Fixed in morph#368 by declining the empty -# key: an empty string identifies no project, so it no longer poses as one. +# every newly constructed holder -- so the empty key has to be declined rather +# than stored: an empty string identifies no project and must not pose as one. # # Asserted here per action because this is the wire surface a client sees, and # because the in-process tests all attach before acting -- the two writers of @@ -63,8 +63,8 @@ expect err message == "AddComment: handler was never attached via OpenBoard" do GetEventsSince lastEventId=0 expect err message == "GetEventsSince: handler was never attached via OpenBoard" -# ...including the two morph#368 singled out, whose guard was written, was -# correct, and was the last thing on this rung anyone expected to be dead. +# ...including the two whose guard is written, is correct, and is the last +# thing on this rung anyone would expect to be dead. do GetActivity expect err message == "GetActivity: handler was never attached via OpenBoard" expect err message ~ "OpenBoard" @@ -84,8 +84,8 @@ expect err message !~ "stoull" # `Remote::attachLogIfConfigured` forwards it to the model verbatim off the # wire, so it is arbitrary text. `foo` used to be adopted as the board id and # answer `stoull` from `std::stoull("foo")` -- the same defect one step over -# from the empty key, and the reason morph#368's guard checks that the key -# parses whole rather than merely that it is non-empty. +# from the empty key, and the reason the guard checks that the key parses whole +# rather than merely that it is non-empty. client garbage model=BoardModel contextKey=foo session principal=$who token=$token diff --git a/scripts/scenario/scenarios/kanban/sign-in-create-project-open-board.scenario b/scripts/scenario/scenarios/kanban/sign-in-create-project-open-board.scenario index a29ffe97c..2949d140d 100644 --- a/scripts/scenario/scenarios/kanban/sign-in-create-project-open-board.scenario +++ b/scripts/scenario/scenarios/kanban/sign-in-create-project-open-board.scenario @@ -18,13 +18,13 @@ # `OpenBoard` before any other action will answer -- and a handler that # was never opened is refused by name, ": handler was never # attached via OpenBoard", which is asserted per action in -# a-board-must-be-opened-before-it-answers.scenario (it answered the raw -# text `stoull` until morph#368); +# a-board-must-be-opened-before-it-answers.scenario (an unguarded key +# answers the raw text `stoull` instead); # * `AuthModel`/`Login` is the one model/action pair a tokenless caller may # reach (`KanbanAuthorizer::kAnonymousModelType`). Everything else needs # the token it mints, installed with a `session` step -- `client`'s own # principal=/token= options take literal text and are not -# capture-expanded (morph#360). +# capture-expanded. # # Re-runnable: the project is created by this file, so its board is empty in # every run and every id below is captured from the reply that minted it. diff --git a/scripts/scenario/scenarios/ledger/accounts-of-every-kind.scenario b/scripts/scenario/scenarios/ledger/accounts-of-every-kind.scenario index 5a077bfcf..cb26a5882 100644 --- a/scripts/scenario/scenarios/ledger/accounts-of-every-kind.scenario +++ b/scripts/scenario/scenarios/ledger/accounts-of-every-kind.scenario @@ -13,7 +13,7 @@ # not have. # # Wire shapes: both enums travel as their *enumerator names*, not as their -# underlying integers (morph#444 gave each a `glz::meta`/`glz::enumerate`) -- +# underlying integers (each carries a `glz::meta`/`glz::enumerate`) -- # AccountKind is "Asset"/"Expense"/"Revenue"/"Liability" and Currency is its # ISO code, "USD"/"EUR"/"JPY"/"KRW". # diff --git a/scripts/scenario/scenarios/ledger/bootstrap-a-book-over-the-wire.scenario b/scripts/scenario/scenarios/ledger/bootstrap-a-book-over-the-wire.scenario index e5b486de8..3d350aa1c 100644 --- a/scripts/scenario/scenarios/ledger/bootstrap-a-book-over-the-wire.scenario +++ b/scripts/scenario/scenarios/ledger/bootstrap-a-book-over-the-wire.scenario @@ -7,10 +7,11 @@ # This is the one ledger file that starts from nothing. Every other file in # this directory uses `ledgerId=1` or `2`, which the driver seeds # (`run_scenarios.py`'s `RungSpec.seed`) — fixture ids, not ids any client -# produced. Until morph#361 that seed was unavoidable: `ledgers` rows were -# created by no registered action, so `OpenAccount ledgerId=1` against a -# genuinely empty database was refused with "OpenAccount: no such ledger" and -# a fresh `ladder_ledger_server` served a book nobody could open. `CreateLedger` +# produced. Without an action that creates one, that seed is unavoidable: +# `ledgers` rows reachable from no registered action mean `OpenAccount +# ledgerId=1` against a genuinely empty database is refused with "OpenAccount: +# no such ledger", and a fresh `ladder_ledger_server` serves a book nobody can +# open. `CreateLedger` # is what closed that, and this file is what proves it: nothing below names a # seeded id, so it would pass unchanged against a database the driver never # touched. @@ -44,7 +45,7 @@ expect ok capture who=$.principal client books model=LedgerModel session principal=$who token=$token -# ── The step that was impossible before morph#361 ─────────────────────────── +# ── The step that needs no seeded id ──────────────────────────────────────── do CreateLedger name="Bootstrapped book" expect ok capture book=$.id diff --git a/scripts/scenario/scenarios/ledger/open-account-transact-report-close.scenario b/scripts/scenario/scenarios/ledger/open-account-transact-report-close.scenario index 4c406ce36..84e295d92 100644 --- a/scripts/scenario/scenarios/ledger/open-account-transact-report-close.scenario +++ b/scripts/scenario/scenarios/ledger/open-account-transact-report-close.scenario @@ -43,7 +43,7 @@ expect ok field principal == "alice" # ── Open the ledger, carrying the minted session ──────────────────────────── # NOTE: `client`'s own principal=/token= options are NOT capture-expanded -- # they take literal text. The credentials a Login just minted have to be -# installed with a `session` step, which is. See morph#360. +# installed with a `session` step, which is. client books model=LedgerModel session principal=$who token=$token @@ -127,8 +127,8 @@ expect err message == "RunReportJob: only the report runner may run a report job # that names no journal is refused rather than silently doing nothing, and the # refusal says nothing about the entries that do exist. # -# A *real* journal id is nameable now -- `ListTransactions` hands them out -# (morph#428) -- and the reversal it makes possible is driven end to end by +# A *real* journal id is nameable -- `ListTransactions` hands them out -- +# and the reversal that makes possible is driven end to end by # `store-list-and-undo-an-entry.scenario`. This file keeps the guessed id on # purpose, because the refusal is a different path from the reversal. do UndoTransaction ledgerId=1 journalId=999999 diff --git a/scripts/scenario/scenarios/ledger/store-list-and-undo-an-entry.scenario b/scripts/scenario/scenarios/ledger/store-list-and-undo-an-entry.scenario index 3ea3f405b..e710dace1 100644 --- a/scripts/scenario/scenarios/ledger/store-list-and-undo-an-entry.scenario +++ b/scripts/scenario/scenarios/ledger/store-list-and-undo-an-entry.scenario @@ -13,10 +13,10 @@ # only input is that id, and a client could only ever pass a number it had # guessed — so the only assertable path was the not-found refusal. # -# `ListTransactions` closed that (morph#428), the same way `CreateLedger` -# closed the equivalent hole one level up (morph#361/#384): by adding the -# action, not by documenting the gap. So this file now drives the reversal -# itself, and the step that proves the gap is closed is the capture below — +# `ListTransactions` closes that, the same way `CreateLedger` closes the +# equivalent hole one level up: by adding the action, not by documenting the +# gap. So this file drives the reversal itself, and the step that proves the +# gap is closed is the capture below — # `capture journal=$.entries[0].id` could not resolve against any reply the # tree produced before this change. # @@ -46,13 +46,11 @@ # `GetBudgetReport` uses — and bounds the answer to a half-open UTC # `[start, end)` range over the entry's stored date; # * enums travel as their *enumerator names*, not as integers: -# `kind="Asset"`, `currency="EUR"`. That is what morph#444 made of them -# when it gave every ledger enum a `glz::meta`/`glz::enumerate` -# (ledger/core/types.hpp), and it is what a real server accepts today. -# Every other file in this directory sends the same enumerator names -- -# they spelled the pre-#444 integers until morph#460 rewrote them, which -# is why nothing in this directory carries a bare integer for an enum any -# more. +# `kind="Asset"`, `currency="EUR"`. That follows from every ledger enum +# carrying a `glz::meta`/`glz::enumerate` (ledger/core/types.hpp), and it +# is what a real server accepts. Every other file in this directory sends +# the same enumerator names, which is why nothing here carries a bare +# integer for an enum. # ── Sign in ──────────────────────────────────────────────────────────────── client auth model=AuthModel @@ -150,7 +148,7 @@ expect ok field entries !~ "\"id\"" # its own event, not a copy of the original's date), so it lands in whatever # month this file is run in. A month after August would therefore be empty or # not depending on the wall clock, which is the dependence this runner forbids -# by design (morph#147); a month before it never can be. +# by design; a month before it never can be. do ListTransactions ledgerId=$book month="2026-07" expect ok field entries !~ "first entry" expect ok field entries !~ "\"id\"" diff --git a/scripts/scenario/scenarios/ledger/submit-a-report-and-poll-it.scenario b/scripts/scenario/scenarios/ledger/submit-a-report-and-poll-it.scenario index 5ba326665..b7cc0715a 100644 --- a/scripts/scenario/scenarios/ledger/submit-a-report-and-poll-it.scenario +++ b/scripts/scenario/scenarios/ledger/submit-a-report-and-poll-it.scenario @@ -15,7 +15,7 @@ # # So a scenario can submit, and can observe Pending. It cannot observe Done: # that needs the server's own runner to tick, and waiting for a tick means a -# sleep, which this runner forbids by design (morph#147). What it *can* do is +# sleep, which this runner forbids by design. What it *can* do is # assert the authorization boundary that makes the wait unavoidable, which is # the file's closing section and the evidence for RunReportJob's entry in # coverage_allowlist.json. @@ -28,7 +28,7 @@ # # Asserting `status == "Pending"` therefore makes a scenario depend on # wall-clock timing through the back door, which is exactly what this runner -# exists not to do (morph#147) -- and it was caught the way such things should +# exists not to do -- and it was caught the way such things should # be, by a mutation run whose repeated passes gave the runner time to tick: the # mutant `status != "Pending"` survived, because by then the job had genuinely # completed. diff --git a/scripts/scenario/scenarios/ledger/two-books-are-isolated.scenario b/scripts/scenario/scenarios/ledger/two-books-are-isolated.scenario index ed3165ba7..682005722 100644 --- a/scripts/scenario/scenarios/ledger/two-books-are-isolated.scenario +++ b/scripts/scenario/scenarios/ledger/two-books-are-isolated.scenario @@ -79,14 +79,14 @@ expect ok # The *second* book's account under the *first* book's category. Both rows # exist and both ids are well-formed -- category and account ids are # table-wide autoincrements, so a lookup by id alone finds the foreign row -- -# and until morph#373 the link was written. The report still did not count it, -# because it filters journals by the budget's own ledger; but that made the -# whole isolation rest on an invariant nothing stated and nothing bound a -# future report kind to. Refused at the write instead. +# so without a check at the write the link is simply written. The report still +# would not count it, because it filters journals by the budget's own ledger; +# but that leaves the whole isolation resting on an invariant nothing states +# and nothing binds a future report kind to. Refused at the write instead. # # These books are unowned (the driver seeds them by raw `INSERT`), which is -# exactly the case morph#382's ownership gate admits: every principal passes -# the owner check on both, so this refusal is the only one left. +# exactly the case the ownership gate admits: every principal passes the owner +# check on both, so this refusal is the only one left. do LinkAccountToCategory accountId=$secondSpend categoryId=$category expect err message == "LinkAccountToCategory: category does not belong to this ledger" @@ -134,8 +134,8 @@ expect ok field limit.num == 100000 # # Each book gets its own job, and each job id answers for itself. # -# Why neither assertion below says `status == "Pending"`, which is what they -# used to say (morph#765): `ledger::app::App` starts a timer that sweeps for +# Why neither assertion below says `status == "Pending"`, which is the obvious +# thing to assert: `ledger::app::App` starts a timer that sweeps for # `Pending` rows every second -- its `runInterval` default, in # examples/ledger/include/ledger/app/app.hpp -- and dispatches `RunReportJob` # under its own service principal. So a job's status is a *race* against that @@ -143,8 +143,8 @@ expect ok field limit.num == 100000 # milliseconds between the `SubmitReport` above and the `GetReportStatus` # below makes the status `Done` and fails the file through no fault of the # code under test. That is wall-clock timing through the back door, which is -# what this runner exists not to do (morph#147). It is not a newly noticed -# hazard either: `submit-a-report-and-poll-it.scenario` documents this exact +# what this runner exists not to do. It is not a hypothetical hazard either: +# `submit-a-report-and-poll-it.scenario` documents this exact # race in its own header and records a `status != "Pending"` mutant surviving # there, because by the time the mutant ran the job had genuinely completed. # @@ -153,7 +153,7 @@ expect ok field limit.num == 100000 # `RunReportJob` settles a job `Failed`, terminally, when the ledger it is # dispatched with is not the ledger the job's own row names -- the scope guard # in `LedgerModel::execute(const RunReportJob&)`, which exists because without -# it book two's job was settled `Done` carrying book one's totals (morph#371). +# it book two's job settles `Done` carrying book one's totals. # The runner reads `jobId` and `ledgerId` off one row, so a job that stays out # of `Failed` across however many sweeps have happened is a job that was not # run against the other book. Whether it has run at all is the sweeper's diff --git a/scripts/scenario/scenarios/pastebin/expire-then-read.scenario b/scripts/scenario/scenarios/pastebin/expire-then-read.scenario index cdba56c13..28ca19d09 100644 --- a/scripts/scenario/scenarios/pastebin/expire-then-read.scenario +++ b/scripts/scenario/scenarios/pastebin/expire-then-read.scenario @@ -14,8 +14,8 @@ # # ── Why this needs no clock and no sleep ──────────────────────────────────── # The expiry instant is *sent by the scenario*, in the past. Nothing here -# waits for time to pass, so this cannot become the kind of flaky timing test -# morph#147 was. ExpirePaste's own DELETE carries an `expires_at_ms <= now` +# waits for time to pass, so this cannot become a flaky timing test. +# ExpirePaste's own DELETE carries an `expires_at_ms <= now` # guard, which is what makes it replay-safe and is also why it must be given a # genuinely expired paste to have any effect at all. # diff --git a/scripts/scenario/scenarios/pastebin/wire-kinds-and-typeid-refusals.scenario b/scripts/scenario/scenarios/pastebin/wire-kinds-and-typeid-refusals.scenario index 8f282af97..659ad4b9f 100644 --- a/scripts/scenario/scenarios/pastebin/wire-kinds-and-typeid-refusals.scenario +++ b/scripts/scenario/scenarios/pastebin/wire-kinds-and-typeid-refusals.scenario @@ -67,7 +67,8 @@ expect ok field @body ~ "GetPaste" expect ok field @body ~ "ExpirePaste" expect ok field @body ~ "\"type\":\"object\"" # CreatePaste's declared burn-budget bounds reach the client as standard -# JSON-Schema keys -- morph#310's whole point. +# JSON-Schema keys, which is what makes the served document usable by a +# generic client. expect ok field @body ~ "burnAfterReads" expect ok field @body ~ "minimum" expect ok field @body ~ "multipleOf" diff --git a/scripts/test_check_automoc_includes.sh b/scripts/test_check_automoc_includes.sh index 205b50d0a..4214867b6 100755 --- a/scripts/test_check_automoc_includes.sh +++ b/scripts/test_check_automoc_includes.sh @@ -2,7 +2,7 @@ # Usage: bash scripts/test_check_automoc_includes.sh # # Self-test for scripts/check_automoc_includes.sh, the CI gate for moc output -# that includes its header by an ascending relative path (issue #372). A lint +# that includes its header by an ascending relative path. A lint # gate that is never itself tested reports green whether or not it still # detects anything -- and this one is especially exposed to that, because the # defect it guards against is invisible in an ordinary checkout: the ascending diff --git a/scripts/test_check_catch2_pin.sh b/scripts/test_check_catch2_pin.sh index 457370e6b..ed7a713a0 100755 --- a/scripts/test_check_catch2_pin.sh +++ b/scripts/test_check_catch2_pin.sh @@ -148,7 +148,7 @@ expect_caught "a doc asserting CI pins catch2 3.5.3 while ci.yml pins 3.4.0" \ >> docs/spec/testing_strategy.md" \ "states 'CI pins catch2 3.5.3', but .github/workflows/ci.yml pins catch2 3.4.0" -# The direction morph#666 will actually take: the runner image moves, someone +# The direction this actually moves in: the runner image changes, someone # updates CATCH2_VERSION, and the nine .clang-tidy copies stay where they are. expect_caught "ci.yml bumped to 3.5.3 while the nine copies still say 3.4.0" \ "edit .github/workflows/ci.yml -e 's/^ CATCH2_VERSION: \"3.4.0\"/ CATCH2_VERSION: \"3.5.3\"/'" \ @@ -205,7 +205,7 @@ expect_caught "--strict with no Catch2 installed at all" \ # Without --strict -- a workstation run -- a divergence is reported rather than # failed, because a workstation is not required to carry the runner's package. -# But it must be *reported*: silence here is the defect morph#666 is about. +# But it must be *reported*: silence here is the defect this gate is about. expect_accepted "a workstation whose Catch2 differs is warned, not failed" \ "true" \ "A local clang-tidy-diff run is therefore NOT the measurement" \ diff --git a/scripts/test_check_coverage_objects.sh b/scripts/test_check_coverage_objects.sh index 877717026..2aad1d6f5 100755 --- a/scripts/test_check_coverage_objects.sh +++ b/scripts/test_check_coverage_objects.sh @@ -2,7 +2,7 @@ # Usage: bash scripts/test_check_coverage_objects.sh # # Self-test for scripts/check_coverage_objects.sh, the gate that fails when -# ctest runs a binary llvm-cov is never handed (morph#403). A lint gate that is +# ctest runs a binary llvm-cov is never handed. A lint gate that is # never itself tested reports green whether or not it still detects anything -- # and this one is especially exposed to that, because the defect it guards # against is *already* a silence: an unprofiled suite makes the coverage run @@ -110,7 +110,7 @@ else fi # ── 2. an unprofiled, unexplained binary -> fail, naming it ────────────────── -# This is morph#403 itself, in miniature: morph_net_tests runs under ctest and +# This is the defect itself, in miniature: morph_net_tests runs under ctest and # is absent from the object list. A nonzero exit alone is not enough -- the # gate has other failure paths, and one of them firing for an unrelated reason # would look like a pass of this case -- so the message must name the binary. diff --git a/scripts/test_check_coverage_profiles.sh b/scripts/test_check_coverage_profiles.sh index 7aea06c5d..b6dab2b02 100644 --- a/scripts/test_check_coverage_profiles.sh +++ b/scripts/test_check_coverage_profiles.sh @@ -2,7 +2,7 @@ # Usage: bash scripts/test_check_coverage_profiles.sh # # Self-test for scripts/check_coverage_profiles.sh, the profile-discovery gate -# extracted from scripts/coverage.sh for morph#430. A gate nobody tests reports +# extracted from scripts/coverage.sh. A gate nobody tests reports # green whether or not it still detects anything, and this one guards exactly # the kind of defect that is a silence: an empty profile set with no failure # is a report computed over nothing, printed as if it were real. @@ -22,7 +22,7 @@ # # What this file does NOT assert, stated rather than left to be discovered: # that a *stale* file from a previous run is excluded. check_coverage_profiles.sh -# does not filter on age; morph#430's actual fix is that scripts/coverage.sh +# does not filter on age; the fix is that scripts/coverage.sh # deletes every path this gate returns once it has merged them, so nothing # stale is ever left for a later run's find to pick up. That deletion is # scripts/coverage.sh's own end-of-run `rm -f $PROFILES`, not a claim in this @@ -97,7 +97,7 @@ fi # This is the shape scripts/coverage.sh's own comment documents: ctest's # working directory nests "$OUT" under itself, so real profile data lands at # $OUT/tests/build/clang-coverage/*.profraw, not directly under $OUT. The find -# has to be recursive to find that at all; morph#430's fix bounds it by +# has to be recursive to find that at all; the fix bounds it by # deleting what it merges, not by narrowing the search. dir="$(case_dir nested)" mkdir -p "${dir}/tests/build/clang-coverage" diff --git a/scripts/test_check_coverage_roots.sh b/scripts/test_check_coverage_roots.sh index a5a93a3d8..b5c7e5730 100755 --- a/scripts/test_check_coverage_roots.sh +++ b/scripts/test_check_coverage_roots.sh @@ -2,7 +2,7 @@ # Usage: bash scripts/test_check_coverage_roots.sh # # Self-test for scripts/check_coverage_roots.sh, the gate that fails when the -# coverage mapping names a file outside this checkout (morph#426). +# coverage mapping names a file outside this checkout. # # The defect that gate exists to catch is a silence: a compiler cache serves an # object built in another worktree, its coverage records carry that worktree's @@ -20,7 +20,7 @@ # Asserts five directions: # # 1. every file under the checkout -> pass -# 1b. a file in the configured dependency cache -> pass (morph#552) +# 1b. a file in the configured dependency cache -> pass # 1c. a foreign worktree, cache also configured -> still fail # 2. one file under another worktree -> fail, naming it # 3. a sibling directory sharing the root's prefix -> fail @@ -86,7 +86,7 @@ else cat "$tmp/1.out" >&2 fi -# 2. One record from another worktree -- the morph#426 shape exactly. +# 2. One record from another worktree -- the shape this gate exists for. write_export "$tmp/foreign.json" \ "${repo_root}/include/morph/core/bridge.hpp" \ "/home/somebody/repo/morph-wt/999/examples/crm/src/models/account_model.cpp" @@ -101,7 +101,7 @@ else fi fi -# 2b. The dependency cache is admitted (morph#552): its trees are third-party +# 2b. The dependency cache is admitted: its trees are third-party # sources that coverage.sh filters out anyway, and they live outside the # checkout only because DepCache.cmake shares them across a run's dozen # configures instead of re-cloning each time. diff --git a/scripts/test_check_ctest_name_collisions.sh b/scripts/test_check_ctest_name_collisions.sh index 10fb03b28..827d0ee34 100755 --- a/scripts/test_check_ctest_name_collisions.sh +++ b/scripts/test_check_ctest_name_collisions.sh @@ -2,7 +2,7 @@ # Usage: bash scripts/test_check_ctest_name_collisions.sh # # Self-test for scripts/check_ctest_name_collisions.sh, the gate that fails -# when two ctest tests in one build tree share a name (morph#464). +# when two ctest tests in one build tree share a name. # # A lint gate nobody tests reports green whether or not it still detects # anything, and this one guards a defect that is *already* silent: two @@ -86,7 +86,7 @@ else fi # ── 2. one name registered twice -> fail, naming it and both binaries ──────── -# This is morph#464 itself, in miniature: two rung binaries defining a +# This is the defect itself, in miniature: two rung binaries defining a # TEST_CASE of the same name, each registered under that bare name. dir="$(case_dir duplicate_pair)" json="$(ctest_json "$dir" \ diff --git a/scripts/test_check_install_export.sh b/scripts/test_check_install_export.sh index 9ff216e4e..80172e85d 100755 --- a/scripts/test_check_install_export.sh +++ b/scripts/test_check_install_export.sh @@ -6,7 +6,7 @@ # # A gate nobody tests reports green whether or not it still detects anything, # and this one guards a defect whose entire character was reporting success: -# `cmake --install` exited 0 while installing none of morph (morph#232). A +# `cmake --install` exited 0 while installing none of morph. A # check for that which itself passed against a broken install would be worse # than no check at all -- it would turn "nobody looked" into "something looked # and said it was fine". @@ -14,7 +14,7 @@ # So the gate is checked in both directions: the unmodified tree must pass, # and each defect it claims to catch is reintroduced into a scratch copy of the # tree, one at a time, and must be caught *for the stated reason*. The -# mutations below are not hypothetical: the first is morph#232 itself, three +# mutations below are not hypothetical: the first is that defect itself, three # more were live bugs in these install rules that survived a reading of the # CMake and were found only by installing to a prefix and building something # against it, and the last is a vacuity guard on the check that caught one of @@ -172,7 +172,7 @@ expect_caught "morph's install rules not running at all" \ # reports the rest. So it moves whenever a public header gains a detail/ include # that sorts ahead of the previous first -- it read `morph/detail/fixed_string.hpp` # until `morph/core/backend.hpp` started including `detail/instance_directory.hpp` -# (morph#523), which the consumer reaches earlier. A failure here saying "caught +# which the consumer reaches earlier. A failure here saying "caught # for the WRONG reason" and naming some other detail/ header is that, and the fix # is to update this needle, not to touch the install rules. The needle stays # specific rather than becoming a loose `detail/` match so that this case still @@ -193,7 +193,7 @@ expect_caught "the detail/ header set dropped from the install" \ # relying on a real one being broken. It used to point at # morph/detail/quantity_equation.hpp, which was included partway down # quantity.hpp and used `formatOptionalDecimal` before that header declared it. -# morph#574 made it self-contained -- a good change for clang-tidy and for any +# it is self-contained -- a good property for clang-tidy and for any # tool that opens a header on its own -- and this case went red, correctly: no # detail/ header was left that VERIFY_INTERFACE_HEADER_SETS would reject, so # deleting the property no longer broke anything and the mutation could not be diff --git a/scripts/test_check_sanitizer_instrumentation.sh b/scripts/test_check_sanitizer_instrumentation.sh index a3a66e329..3bd7516d2 100755 --- a/scripts/test_check_sanitizer_instrumentation.sh +++ b/scripts/test_check_sanitizer_instrumentation.sh @@ -3,12 +3,12 @@ # # Self-test for scripts/check_sanitizer_instrumentation.sh, the gate that fails # when a sanitizer leg runs a binary carrying none of that sanitizer's runtime -# symbols (morph#542). A lint gate nobody tests reports green whether or not it +# symbols. A lint gate nobody tests reports green whether or not it # still detects anything, and this one guards a silence: an uninstrumented # sanitizer leg builds, runs the whole suite, passes, and costs its full # runtime -- indistinguishable from a leg that found nothing wrong. # -# morph#675 added a second mode (`--binary `) for the question the +# A second mode (`--binary `) answers the question the # sweep refused: "is this one binary instrumented?", asked by a developer who # built a single target under a sanitizer preset. The important property of # that mode is not what it reports but where it cannot be used -- if it could @@ -141,7 +141,7 @@ else fi # ── 2. sweep: one uninstrumented binary -> fail, naming it ────────────────── -# morph#542 itself, in miniature. A nonzero exit alone is not enough: the gate +# The defect itself, in miniature. A nonzero exit alone is not enough: the gate # has several failure paths and one of them firing for an unrelated reason # would look like a pass of this case, so the message must name the binary. dir="$(case_dir sweep_dirty)" @@ -161,7 +161,7 @@ fi # ── 2b. the symbol is keyed on the mode ───────────────────────────────────── # The same tree that passes as `ubsan` must fail as `tsan`. Without this, an -# `__asan_`-only assertion -- the exact defect morph#542 records -- would +# `__asan_`-only assertion -- the exact defect this gate exists for -- would # satisfy every other case here. dir="$(case_dir sweep_wrong_mode)" a="$(make_binary "$dir" morph_tests __ubsan_)" @@ -186,13 +186,13 @@ elif ! mentions 'listed no tests' "$output"; then fail "the empty test list was rejected, but not for being empty:" printf '%s\n' "$output" >&2 elif ! mentions 'genuinely registers no tests' "$output"; then - fail "the empty test list was rejected, but the message does not say ctest succeeded -- it reads the same as a listing that failed, which is morph#690:" + fail "the empty test list was rejected, but the message does not say ctest succeeded -- it reads the same as a listing that failed:" printf '%s\n' "$output" >&2 else note "ok: an empty ctest test list is rejected rather than passing vacuously, and named as empty rather than broken" fi -# ── 3b. sweep: ctest *fails* to list -> the reason is printed (morph#690) ─── +# ── 3b. sweep: ctest *fails* to list -> the reason is printed ─────────────── # The distinction case 3 cannot make on its own, and the one that cost three CI # runs: "ctest enumerated a tree with no tests" and "ctest died before printing # any JSON" both arrive here as an empty list. On the bank-ubsan leg it was the @@ -213,7 +213,7 @@ if output="$(run_checker "$dir" ubsan 2>&1)"; then fail "a ctest listing that failed outright was reported as clean:" printf '%s\n' "$output" >&2 elif ! mentions 'morph690_fixture_marker' "$output"; then - fail "the failed listing was rejected, but ctest's own reason was discarded -- the caller is left with 'listed no tests' and no cause, which is morph#690:" + fail "the failed listing was rejected, but ctest's own reason was discarded -- the caller is left with 'listed no tests' and no cause:" printf '%s\n' "$output" >&2 else note "ok: a ctest listing that failed prints the reason it failed" @@ -253,7 +253,7 @@ else fi # ── 6. narrow: refused under GITHUB_ACTIONS ──────────────────────────────── -# The whole safety of morph#675's addition. If this passes, the narrow mode is +# The whole safety of the narrow mode. If this passes, it is # reachable from a workflow step, and a leg could report "instrumented" having # examined one file -- which is the sweep's floor removed by another route. # Asserted on a fixture that would otherwise *pass* (case 4's), so a refusal @@ -280,7 +280,7 @@ else fi # ── 7. sweep: a single-binary tree still fails on the floor ──────────────── -# morph#675 is about making the narrow case answerable, not about lowering the +# The narrow mode is about making one binary answerable, not about lowering the # floor. This is the regression guard for the difference: the sweep over a # one-binary tree must still refuse, with the floor's own message, even though # that binary is instrumented. diff --git a/scripts/test_check_tidy_suppression_scope.sh b/scripts/test_check_tidy_suppression_scope.sh index d7822fe04..a616b616c 100755 --- a/scripts/test_check_tidy_suppression_scope.sh +++ b/scripts/test_check_tidy_suppression_scope.sh @@ -2,7 +2,7 @@ # Usage: bash scripts/test_check_tidy_suppression_scope.sh [CLANG_TIDY_BINARY] # # Self-test for scripts/check_tidy_suppression_scope.sh, the gate that keeps -# tests/.clang-tidy's record of its own reach true (morph#632). +# tests/.clang-tidy's record of its own reach true. # # A lint gate nobody tests reports green whether or not it still detects # anything. This one is exposed to that twice over: the record it checks is From cce50e8859e7fac21813fe6786a84f6114a1e26e Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 23 Sep 2026 21:10:45 +0200 Subject: [PATCH 5/6] tests: comments that say what the case proves, not which ticket asked for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 457 tracker references across 97 files under `tests/` — 364 `morph#NNN` and 93 of the `issue #NNN` / bare `#NNN` forms — replaced by the constraint each case exists to pin. A test comment that reads "regression coverage for morph#NNN" tells a reader nothing about what breaks if the case is deleted; each is rewritten to say that instead, in the present tense. Measured blocks stay: `bench_dispatch_allocations.cpp`'s 8.06-per-call figure and its 84-process spread, `bench_dispatch_latency.cpp`'s 302x polling swing and its injected-delay table, `test_bridge_lifetime.cpp`'s 0/200-under-ASan against 26/200 unsanitized, `test_quantity.cpp`'s render timings, `tests/CMakeLists.txt`'s load-average sweeps, `.clang-tidy`'s 751-entry database measurement. **User-visible strings changed**, which is the part to review closely: - **21 `TEST_CASE` names** lost a trailing ` (morph#NNN)` — these are ctest entry names as well as text a person reads in a failure report. Nothing in the repository filters on any of them. - One exception, deliberately kept: `attachHandlerAsync's out-of-frame success callback ... (morph#108)` in `test_async_registration.cpp`. `ci.yml`'s clang-asan/clang-tsan legs exclude tests by name with `-E "OomInjector|morph#108"`, so that token is a selector. Renaming it without editing the workflow would silently stop excluding the case and turn both legs red. `tests/oom_injector.cpp`'s comment now says so explicitly. - `test_strand_race.cpp`'s watchdog line, printed to `stderr` from a second thread, no longer prefixes itself with a ticket number. - `tests/compile_checks/forms_dag_budget.cmake` and `demote_interface_includes_selftest.cmake` — three `FATAL_ERROR` messages. - `tests/net/test_socket_server.cpp` — one `FAIL(...)`. Left as they are, and why: Catch2 tags (`[issue26]`, `[morph583]`, …) are identifiers, not prose; the net audit's own `finding #6`/`#7`/`#8`/`#10`/`#11` labels are that audit's vocabulary rather than tracker references, and appear beside `ST1`/`BK2`/`S3` in the same files; and `test_tcp_socket.cpp` quotes a real ctest line verbatim (`Test #1696: ...`) as evidence, not as a citation. clang-format (22.1.8) re-wrapped the lines these edits reflowed; the whole changed set is clean under `--dry-run -Werror`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW --- tests/.clang-tidy | 8 +- tests/CMakeLists.txt | 33 ++++---- tests/bench/CMakeLists.txt | 32 ++++---- tests/bench/bench_dispatch_allocations.cpp | 77 +++++++++---------- tests/bench/bench_dispatch_latency.cpp | 62 +++++++-------- .../client_only_facade_no_model_header.cpp | 2 +- .../demote_interface_includes_selftest.cmake | 11 ++- tests/compile_checks/forms_dag_budget.cmake | 12 +-- tests/compile_checks/forms_dag_probe.cpp | 11 +-- tests/compile_checks/journal_skew_probe.cpp | 4 +- tests/forms_rule_corpus.hpp | 4 +- tests/fuzz/CMakeLists.txt | 2 +- .../long_climb/moc_budget_presenter.cpp | 2 +- tests/net/CMakeLists.txt | 2 +- tests/net/test_handshake_over_socket.cpp | 8 +- tests/net/test_socket_backend.cpp | 56 +++++++------- tests/net/test_socket_server.cpp | 16 ++-- tests/net/test_tcp_socket.cpp | 65 ++++++++-------- tests/net/test_ws_frame.cpp | 4 +- tests/net_qt_interop/CMakeLists.txt | 7 +- tests/offline_queue_conformance.hpp | 7 +- tests/offline_sqlite/CMakeLists.txt | 4 +- .../test_sqlite_offline_queue.cpp | 35 ++++----- tests/oom_injector.cpp | 11 +-- tests/oom_injector.hpp | 4 +- tests/qt/CMakeLists.txt | 8 +- tests/qt/test_qt_executor_teardown.cpp | 2 +- tests/qt/test_qt_websocket.cpp | 38 +++++---- tests/replay_ledger_conformance.hpp | 2 +- tests/soak/CMakeLists.txt | 2 +- tests/test_action_log.cpp | 4 +- tests/test_action_log_phase2.cpp | 20 ++--- tests/test_async_registration.cpp | 64 +++++++-------- tests/test_backend_extra.cpp | 6 +- tests/test_backend_registration_surface.cpp | 18 ++--- tests/test_bridge_fixes.cpp | 2 +- tests/test_bridge_lifetime.cpp | 14 ++-- tests/test_bridge_pending_calls.cpp | 12 +-- tests/test_callback_scope.cpp | 6 +- tests/test_client_execute_deadline.cpp | 6 +- tests/test_completion_branches.cpp | 2 +- tests/test_completion_multi_handler.cpp | 17 ++-- tests/test_completion_promise.cpp | 4 +- tests/test_completion_value_contract.cpp | 6 +- tests/test_coverage_gaps.cpp | 4 +- tests/test_dispatch_di.cpp | 2 +- tests/test_execute_order_gate.cpp | 15 ++-- tests/test_executor.cpp | 2 +- tests/test_file_io_ops.cpp | 4 +- tests/test_file_offline_queue.cpp | 37 ++++----- tests/test_forms_boolean_anyof_wire.cpp | 7 +- tests/test_forms_dom_access.cpp | 2 +- tests/test_forms_exact_bounds.cpp | 7 +- tests/test_forms_field_bounds.cpp | 1 - tests/test_forms_instance_constraints.cpp | 6 +- tests/test_forms_layout.cpp | 2 +- tests/test_forms_rule_agreement.cpp | 2 +- tests/test_forms_rule_corpus.cpp | 2 +- tests/test_forms_rules.cpp | 18 ++--- tests/test_journal_payload_evolution.cpp | 8 +- tests/test_logger.cpp | 6 +- tests/test_nested_forms.cpp | 34 ++++---- tests/test_offline_queue.cpp | 5 +- tests/test_opaque_model_ids.cpp | 2 +- tests/test_outbox.cpp | 4 +- tests/test_principal.cpp | 4 +- tests/test_quantity.cpp | 16 ++-- tests/test_quantity_forms.cpp | 12 +-- tests/test_rational.cpp | 8 +- tests/test_rational_checked.cpp | 8 +- tests/test_registration_phase.cpp | 2 +- tests/test_registration_qualified_types.cpp | 2 +- tests/test_registration_same_line.cpp | 4 +- tests/test_registry_schema_forgery.cpp | 2 +- tests/test_remote_connection_scope.cpp | 2 +- tests/test_remote_execute_ordering.cpp | 38 ++++----- tests/test_remote_reply_envelopes.cpp | 4 +- tests/test_remote_step_interleaving.cpp | 2 +- tests/test_render_locale_format.cpp | 44 ++++++----- tests/test_replay_ledger.cpp | 2 +- tests/test_reply_router.cpp | 2 +- tests/test_sections.cpp | 2 +- tests/test_server_limits.cpp | 2 +- tests/test_shared_instances.cpp | 6 +- tests/test_strand.cpp | 2 +- tests/test_strand_race.cpp | 43 +++++------ tests/test_strong_id_keys.cpp | 5 +- tests/test_support.hpp | 22 +++--- tests/test_switch_backend.cpp | 4 +- tests/test_sync_worker.cpp | 2 +- tests/test_timeout_scheduler.cpp | 2 +- tests/test_widget_hints.cpp | 6 +- tests/test_wire_hardening.cpp | 2 +- tests/test_wire_omitted_fields.cpp | 6 +- tests/test_wire_schemas.cpp | 15 ++-- 95 files changed, 567 insertions(+), 586 deletions(-) diff --git a/tests/.clang-tidy b/tests/.clang-tidy index c6802c02a..6a45461dd 100644 --- a/tests/.clang-tidy +++ b/tests/.clang-tidy @@ -8,7 +8,7 @@ # # One entry per check, with the reason it cannot fire on anything worth fixing. # -# ── These suppressions are NOT confined to test sources (morph#632) ────────── +# ── These suppressions are NOT confined to test sources ───────────────────── # # clang-tidy resolves its configuration from the path of the *translation unit* # it is analysing, not from the path of the file a diagnostic lands in. This @@ -140,7 +140,7 @@ # `readability-function-cognitive-complexity.IgnoreMacros: true` instead, which # stops it counting increments expanded from inside a macro body -- the whole # Catch2 assertion vocabulary -- while still scoring everything the test author -# wrote (morph#778). Measured on 68a30bcc with clang-tidy 22.1.8 over the +# wrote. Measured with clang-tidy 22.1.8 over the # clang-tidy-diff job's own 751-entry database, with the check forced on in # every directory: 246 TEST_CASE bodies score over 25 without the option and # 4 with it. Three of those four are under tests/ -- @@ -152,8 +152,8 @@ # # -- so narrowing this entry to the option is a real option rather than a # theory, but it is not free: it would newly fail those four bodies, two of -# them in a file another branch is editing. Narrowing it is morph#787, filed -# with those figures and the order of operations, rather than done here. +# them in a file another branch is editing. Narrowing it is separate work, +# not done here. # readability-convert-member-functions-to-static: fires on the `execute` methods # of fixture models. A stub action handler ignores its own state, which is diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f57393775..d7e480de2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,4 +1,4 @@ -# ── The SYSTEM-include demotion helper's own self-test (morph#438) ─────────── +# ── The SYSTEM-include demotion helper's own self-test ────────────────────── # cmake/morph_demote_interface_includes.cmake moves unixODBC's `-I` out of the # fetched Lightweight target's INTERFACE_COMPILE_OPTIONS. On Linux that flag is # empty, so nothing observable here would change if the helper's body were @@ -165,7 +165,7 @@ target_compile_definitions(morph_tests PRIVATE MORPH_CMAKE_VERSION_PATCH=${PROJECT_VERSION_PATCH} ) -# The shared x-rules corpus (morph#176). One file, two readers: this suite and +# The shared x-rules corpus. One file, two readers: this suite and # src/qt/forms/tests/tst_DynamicFormRuleCorpus.qml, both pointed at it from # CMake so neither can quietly grow its own copy. It lives beside the QML half # because that half cannot be handed a path at runtime; this half can. @@ -219,20 +219,20 @@ include(Catch) # That number was never chosen with any particular test in mind, which is # fine while every test is sub-second and wrong for the one that is not: the # longest, most load-sensitive case in the binary was governed by the same -# ceiling as the cheapest (morph#760). So the blanket stays and `[slow]` is -# registered separately, exactly as it was for #589/#590 -- DISCOVERY_MODE +# ceiling as the cheapest. So the blanket stays and `[slow]` is +# registered separately -- DISCOVERY_MODE # PRE_TEST defers discovery to ctest invocation time, so a per-test TIMEOUT # cannot be set with set_tests_properties() here (nothing is named that test # yet at configure time); excluding a tag and giving it its own # catch_discover_tests() call is the mechanism that is actually available. # -# (#589's own use of `[slow]` is gone: the 70,000-node `equation()` depth test -# blew past 120s under TSan because of O(n^2) string building, #582 made that -# rendering linear, and it is back under the blanket cap. The tag is reused +# (The `equation()` depth test no longer needs the tag: at 70,000 nodes it blew +# past 120s under TSan because of O(n^2) string building, and with that +# rendering linear it is back under the blanket cap. The tag is reused # here for a different test and a different reason.) catch_discover_tests(morph_tests DISCOVERY_MODE PRE_TEST TEST_SPEC "~[slow]" PROPERTIES TIMEOUT 120) -# `[slow]` (morph#760): `StrandExecutor never runs two tasks for one key +# `[slow]`: `StrandExecutor never runs two tasks for one key # concurrently under contention`. Not slow because it computes anything -- # 0.14 s on an idle box -- but because the strand serialises 3200 tasks per # iteration and every handoff is a thread wakeup that has to wait its turn on @@ -246,7 +246,7 @@ catch_discover_tests(morph_tests DISCOVERY_MODE PRE_TEST TEST_SPEC "~[slow]" PRO # run queue 38 -> 396.4 s <- load average 31-38 at measurement # # Every one of those runs *passed*: 40 assertions, `inFlight 1, maxInFlight 1` -# throughout. morph#760's report was this case being killed while passing, at +# throughout. What this tag answers is the case being killed while passing, at # load average 29-48 -- above the 38 measured here, and the curve is # superlinear, so the fitted figure at a run queue of ~50 is roughly 730 s. # @@ -266,9 +266,8 @@ catch_discover_tests(morph_tests DISCOVERY_MODE PRE_TEST TEST_SPEC "~[slow]" PRO # for one test out of 3044. catch_discover_tests(morph_tests DISCOVERY_MODE PRE_TEST TEST_SPEC "[slow]" PROPERTIES TIMEOUT 900) -# ── Two-binary journal-path skew test (issue #246) ─────────────────────────── -# The executable form of the journal's data-at-rest contract, which -# examples/lims/README.md asks for and #174 restated as "the other half": an +# ── Two-binary journal-path skew test ─────────────────────────────────────── +# The executable form of the journal's data-at-rest contract: an # old build records a journal, a new build replays it, and a shape that changed # in between must not decode to a plausible default in silence. # @@ -292,8 +291,8 @@ catch_discover_tests(morph_tests DISCOVERY_MODE PRE_TEST TEST_SPEC "[slow]" PROP # suppresses the model-owning registrars, so a client-only binary cannot # execute a model and therefore cannot journal anything. It is the right gate # for the *wire*-path skew test, which needs a per-action fingerprint exchanged -# at `hello` before there is anything to assert against -- issue #207's -# unimplemented proposal. This covers the journal path, which ships today. +# at `hello` before there is anything to assert against, and no such exchange +# exists. This covers the journal path, which ships today. set(MORPH_JOURNAL_SKEW_DIR "${CMAKE_CURRENT_BINARY_DIR}/journal_skew") file(MAKE_DIRECTORY "${MORPH_JOURNAL_SKEW_DIR}") @@ -328,8 +327,8 @@ set_tests_properties(journal_skew_new_build_replays PROPERTIES FIXTURES_REQUIRED morph_journal_skew_fixture TIMEOUT 120) -# ── forms: schema generation must not be route-count sensitive (morph#573) ─── -# The Part B regression guard. compile_checks/forms_dag_probe.cpp is compiled +# ── forms: schema generation must not be route-count sensitive ────────────── +# compile_checks/forms_dag_probe.cpp is compiled # twice — once as a type graph with one route to each node, once as a DAG with # 6,561 routes through the same number of types at the same depth — and # compile_checks/forms_dag_budget.cmake asserts the second costs no more than @@ -561,7 +560,7 @@ if(NOT MORPH_CLIENT_ONLY_RUNTIME_THROW_EXITCODE EQUAL 0) "${MORPH_CLIENT_ONLY_RUNTIME_THROW_RUN_OUTPUT}") endif() -# Issue #61: BRIDGE_REGISTER_ACTION_FOR_CLIENT lets a MORPH_CLIENT_ONLY client +# BRIDGE_REGISTER_ACTION_FOR_CLIENT lets a MORPH_CLIENT_ONLY client # register an action's ActionTraits (JSON codecs + an explicitly-named Result # type) without ever needing the real model's complete class body -- see # docs/spec/core/registry.md, "BRIDGE_REGISTER_ACTION_FOR_CLIENT". diff --git a/tests/bench/CMakeLists.txt b/tests/bench/CMakeLists.txt index 63d3d79bf..ea0b0b6ca 100644 --- a/tests/bench/CMakeLists.txt +++ b/tests/bench/CMakeLists.txt @@ -8,7 +8,7 @@ target_include_directories(morph_bench PRIVATE ${CMAKE_SOURCE_DIR}/tests) target_compile_definitions(morph_bench PRIVATE BENCH_ARTIFACT_DIR="${CMAKE_CURRENT_BINARY_DIR}") apply_warnings(morph_bench) -# Instrumented under the same deliberate decision as tests/soak (morph#542). +# Instrumented under the same deliberate decision as tests/soak. # The numbers a sanitized benchmark reports are not comparable with an # unsanitized one and are not meant to be: on a sanitizer preset this target is # a correctness run over the dispatch path, and the throughput figures it @@ -20,7 +20,7 @@ endif() include(Catch) catch_discover_tests(morph_bench DISCOVERY_MODE PRE_TEST PROPERTIES TIMEOUT 60 LABELS "bench") -# Allocation census for one local dispatch (morph#572). A binary of its own, +# Allocation census for one local dispatch. A binary of its own, # not a case in morph_bench, because it replaces the global operator new: the # replacement is process-wide and would perturb every other measurement in the # same binary. Run it by hand for the numbers and the per-allocation breakdown: @@ -44,9 +44,9 @@ endif() # ── The allocation budget, as a ctest gate ─────────────────────────────────── # -# morph#572 asks for "a benchmark that *fails* when the budget regresses", and -# names why: `tests/bench/bench_dispatch_latency.json` is produced and never -# diffed, which is the "control that measures nothing" failure AGENTS.md warns +# A benchmark has to *fail* when the budget regresses: +# `tests/bench/bench_dispatch_latency.json` produced and never +# diffed is the "control that measures nothing" failure AGENTS.md warns # about. A census nobody compares is the same thing with extra steps, so the # census is compared here. # @@ -58,9 +58,7 @@ endif() # figure measured on the toolchains that actually run this target in CI, and # anything else can set its own. Setting it to 0 disables the gate. # -# Re-measured on this tree with morph#572's Part A, Part B and Part C all -# applied -- Part B landed in morph#743 and took the figure from 14.06 to 8.06, -# which is why the ceiling below is 9.0 and no longer 15.0. 20 processes per +# Measured on this tree at 8.06, which is why the ceiling below is 9.0. 20 processes per # configuration, plus one run of 24 concurrent processes on twelve cores: # # clang 22.1.8 / libstdc++ 16.2.1, Release, idle : 8.06, 20 of 20 @@ -76,9 +74,9 @@ endif() # worker gate this file's benchmark now uses, the figure was bimodal -- ~17 on # an idle machine and ~13 on a loaded one, for the same binary -- because # whether the caller beat the pool thread to attaching its handlers decided -# which `CompletionState` path they took. That is morph#687, and a ceiling -# under it would have been either flaky or blind: the pre-fix loaded mode -# (13.06) sits *below* the post-fix idle mode (14.06), so no single number +# which `CompletionState` path they took. A ceiling over an unpinned race is +# either flaky or blind: the unpinned loaded mode +# (13.06) sits *below* the pinned idle mode (14.06), so no single number # could have separated a regression from a busy runner. With the race pinned # the figure is exact, so 9.0 is not a tolerance band -- it is "one more # allocation per call than we spend now", which is the smallest regression @@ -115,21 +113,21 @@ set(MORPH_ALLOC_BUDGET_PER_CALL "${_morph_alloc_budget_default}" CACHE STRING "Ceiling, in heap allocations per local execute round trip, that morph_bench_alloc enforces as a ctest case. 0 disables the gate. See tests/bench/CMakeLists.txt for how the default was measured.") if(NOT MORPH_ALLOC_BUDGET_PER_CALL EQUAL 0) - # --lookup-budget=0 is exact, not a ceiling with headroom: morph#572's - # Part C made `ActionDispatcher`'s key lookups allocate nothing at all, and - # morph#699 did the same for `journal::PayloadMigrationRegistry::find`, for + # --lookup-budget=0 is exact, not a ceiling with headroom: transparent keys + # make `ActionDispatcher`'s key lookups and + # `journal::PayloadMigrationRegistry::find` allocate nothing at all, for # ids of any length. "Nothing at all" is a property that either holds or # has regressed. There is no toolchain on which a transparent lookup # allocates, so this half needs no per-toolchain default. # - # --id-length-budget covers morph#699's third registry, + # --id-length-budget covers the third registry, # `ActionExecuteRegistry`, which cannot be probed on its own -- it # dispatches whatever it finds -- so what is gated is the gap between an # `executeJson` over ids past libstdc++'s SSO buffer and one over ids # inside it. 0.5 rather than 0 is measured, not padded: the figure reads # 0.01 because of a deterministic two-allocation one-off that follows - # census order rather than id length, and reverting either half of - # morph#699 takes it to 3.00. The binary's own comment at the check + # census order rather than id length, and making either half of that key + # opaque again takes it to 3.00. The binary's own comment at the check # carries the measurement. Like the lookup half it is toolchain-independent # -- it is a difference between two runs of the same build. add_test(NAME bench.alloc_budget diff --git a/tests/bench/bench_dispatch_allocations.cpp b/tests/bench/bench_dispatch_allocations.cpp index 2d3de62d1..b79d1e0b0 100644 --- a/tests/bench/bench_dispatch_allocations.cpp +++ b/tests/bench/bench_dispatch_allocations.cpp @@ -1,12 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 -// Allocation census for one local `execute` round trip, for morph#572. +// Allocation census for one local `execute` round trip. // -// morph#572 is a performance ticket whose scope is set by a number -- "19 -// allocations, two CompletionStates, two strings per dispatch", measured on -// master @ 4017228d. Three pull requests then rewrote the dispatch path -// (morph#639, morph#649, morph#654), which is exactly the situation where a -// fix gets built against a figure nobody has re-checked. This program exists +// Any scoping figure for dispatch cost -- "19 allocations, two +// CompletionStates, two strings per dispatch" -- goes stale the moment +// anything on the path changes, and the dispatch path changes often. That is +// exactly the situation where a fix gets built against a figure nobody has +// re-checked. This program exists // so the figure can be re-checked in one command instead of being rebuilt from // a description of how it was once obtained. // @@ -35,8 +35,8 @@ // * `--attribute` additionally prints the size of every allocation made // during one steady-state call. For per-*line* attribution, build with // `-g -no-pie -rdynamic` and add a `backtrace()` to `note()`, then resolve -// the frames with `llvm-addr2line -a -f -i -C`; that is how the breakdown -// in morph#572's re-measurement comment was produced. `-no-pie` matters: +// the frames with `llvm-addr2line -a -f -i -C`; that is how a per-frame +// breakdown is produced. `-no-pie` matters: // without it the recorded frames are runtime addresses and addr2line // resolves every one of them to `_end`. // * A second group of censuses counts what a *registry lookup* allocates, @@ -45,8 +45,8 @@ // census over one side alone either measures zero or overstates the // saving. `ActionDispatcher` (`coalesce` + `requiredFieldsFor`) and // `journal::PayloadMigrationRegistry::find` decode nothing and execute -// nothing, so their figure *is* the key's cost -- morph#572's Part C and -// morph#699. `ModelRegistryFactory::create` and +// nothing, so their figure *is* the key's cost. +// `ModelRegistryFactory::create` and // `BridgeHandler::executeJson` do more than look up, so theirs is a floor // plus the key, and what is comparable between runs is the difference // between a long-id and a short-id run. @@ -58,12 +58,12 @@ // vector that the settle then drains; lose it and each takes // `CompletionState`'s attach-after-ready path instead. The two cost a // different number of allocations -- **17 and 13 per call** on this workload -// before morph#572 -- so which one a process lands in moves the headline +// with an unpinned race -- so which one a process lands in moves the headline // figure by four allocations for reasons that have nothing to do with the code // under measurement. Measured, interleaved, 20 processes per configuration: // on an idle machine every process reported ~16.95; with the machine -// oversubscribed 16 ways, 18 of 20 reported ~13.06. That is morph#687's -// instability, reproduced and given a cause. +// oversubscribed 16 ways, 18 of 20 reported ~13.06. That is the instability +// this gate pins, reproduced and given a cause. // // `GatedWorkerExecutor` holds the strand task until the caller has attached // everything, so this program always measures the attach-before-settle regime: @@ -75,13 +75,12 @@ // // With the gate in place the figure is *exact*: 8.06 per call in every one of // 84 processes across clang Release, clang Debug and gcc Debug, idle and -// oversubscribed alike (morph#572; it was 14.06 until Part B landed in -// morph#743). Take more than one run anyway -- a single process is a single +// oversubscribed alike. Take more than one run anyway -- a single process is a single // sample -- but if two runs disagree here, something has changed. // -// ── What the registry censuses measured (morph#699) ───────────────────────── +// ── What the registry censuses measure ────────────────────────────────────── // -// On `e9dad027`, x86-64 Linux, clang 22.1.8 / libstdc++ 16.2.1, Release, +// x86-64 Linux, clang 22.1.8 / libstdc++ 16.2.1, Release, // before and after making `ActionExecuteRegistry` and // `PayloadMigrationRegistry` transparent: // @@ -95,7 +94,7 @@ // long id and a short one cost the same, which is the property the // `--lookup-budget` gate now holds. `create` is unchanged on purpose: it runs // per model *instantiation*, not per request, and parking it rather than -// carrying it along for symmetry is morph#709. +// carrying it along for symmetry is deliberately not done. // // Build: `-DMORPH_BUILD_LOAD_TESTS=ON`, target `morph_bench_alloc`. See // docs/spec/testing_strategy.md. @@ -210,9 +209,9 @@ BRIDGE_REGISTER_ACTION(BenchAllocModel, BenchAllocPing, "BenchAlloc_Ping") // Two more registered pairs, existing only to be looked up. Their ids sit // deliberately on either side of libstdc++'s 15-character SSO threshold, so // the lookup census below reports the two cases separately instead of -// averaging them. morph#529 (folded into morph#572 as Part C) left exactly -// that question open: it observed that the registry built two `std::string`s -// per lookup, but not whether morph's own ids are long enough for those +// averaging them. Observing that the registry builds two `std::string`s per +// lookup leaves the load-bearing question open: whether morph's own ids are +// long enough for those // constructions to reach the heap. morph's real ids straddle the boundary -- // this file's own `"BenchAlloc_Model"` is 16 characters and allocates, // `"BenchAlloc_Ping"` is 15 and does not -- so the census measures both ends @@ -343,13 +342,11 @@ constexpr int kWarmup = 50; constexpr int kCalls = 200; constexpr int kLookups = 200; -// Allocation census for `ActionDispatcher`'s key lookups -- morph#572 Part C, -// which is about the server-side dispatch path rather than the client-side one -// `run()` measures. `coalesce` and `requiredFieldsFor` are the two lookups -// that do nothing *but* look up: no JSON is decoded, no runner executes, and -// neither returns anything that has to be built. What they allocate is -// therefore exactly what building the stored `std::pair` key costs, which is the whole of Part C's claim. +// Allocation census for `ActionDispatcher`'s key lookups -- the server-side +// dispatch path rather than the client-side one `run()` measures. `coalesce` and `requiredFieldsFor` are the two +// lookups that do nothing *but* look up: no JSON is decoded, no runner executes, and neither returns anything that has +// to be built. What they allocate is therefore exactly what building the stored `std::pair` +// key costs, which is the whole of Part C's claim. // // Both are warmed first: `requiredFieldsFor` calls a thunk that builds the // action's `ActionDescription` on first use and caches it for the process, so @@ -377,8 +374,8 @@ double lookupCensus(std::string_view modelId, std::string_view actionId) { return static_cast(after - before) / (2.0 * kLookups); } -// Allocation census for `ModelRegistryFactory::create` -- morph#699's first -// site. Unlike the two above this is *not* a pure lookup: `create` calls the +// Allocation census for `ModelRegistryFactory::create`. Unlike the two above +// this is *not* a pure lookup: `create` calls the // registered factory, which news up a holder, and then hands the holder its // primary key. So the figure has a floor that has nothing to do with the key, // and what a fix moves is the difference between two runs of this, not the @@ -407,8 +404,8 @@ double registryCreateCensus(std::string_view modelId) { return static_cast(after - before) / kLookups; } -// Allocation census for `PayloadMigrationRegistry::find` -- morph#699's third -// site, and the only one of the three that is a pure lookup: `find` hashes, +// Allocation census for `PayloadMigrationRegistry::find` -- the only one of +// the three registry sites that is a pure lookup: `find` hashes, // probes and returns a pointer. Nothing else in it can allocate, so the figure // *is* the key's cost and a fix has to take it to zero. // @@ -433,12 +430,10 @@ double migrationFindCensus(std::string_view actionType, std::string_view fromSch } // Allocation census for one `BridgeHandler::executeJson` round trip -- -// morph#699's second site, `ActionExecuteRegistry::execute`, measured through -// the only caller it has. Also not a pure lookup: `executeJson` decodes the -// body, dispatches, runs the action and encodes the result, so most of this -// figure is the codec. That is why it is measured rather than the lookup -// alone -- morph#699 asks how hot the site is before it asks for the fix, and -// "N allocations out of M" is the answer in the form the question wants. +// `ActionExecuteRegistry::execute`, measured through the only caller it has. Also not a pure lookup: `executeJson` +// decodes the body, dispatches, runs the action and encodes the result, so most of this figure is the codec. That is +// why it is measured rather than the lookup alone: how hot the site is decides whether the lookup's cost is worth +// removing, and "N allocations out of M" is the answer in that form. // // Gated exactly as `roundTrip` is, for the reason the file header gives. // @@ -545,7 +540,7 @@ int run(bool attribute, double budget, double lookupBudget, double idLengthBudge << std::format(" both ids past SSO : {:.2f} allocations per lookup\n", longIdLookups) << std::format(" both ids inside SSO : {:.2f} allocations per lookup\n", shortIdLookups); - // morph#699's three sites. Every one is reported for ids past the SSO + // The three registry sites. Every one is reported for ids past the SSO // buffer and again for ids inside it, because the cost is entirely // id-length-dependent and a census over one side only would either // measure zero or overstate the saving. morph's real ids straddle the @@ -584,7 +579,7 @@ int run(bool attribute, double budget, double lookupBudget, double idLengthBudge // `ActionDispatcher`'s lookups and `PayloadMigrationRegistry::find` // are pure lookups -- they hash, probe and return -- so the figure // *is* the key's cost, and zero is the only right answer for it on - // any standard library (morph#572 Part C, morph#699). The third + // any standard library. The third // registry, `ActionExecuteRegistry`, is gated separately by // `--id-length-budget` below, because it can only be reached through // a whole `executeJson` round trip. @@ -619,7 +614,7 @@ int run(bool attribute, double budget, double lookupBudget, double idLengthBudge // // 0.5 separates that residue from the thing being guarded by a wide // margin in both directions: the residue is 0.01, and reverting - // either half of morph#699's change to this path takes the gap to + // either half of the transparent key on this path takes the gap to // 3.00 per call (two `std::string`s for the `Key`, one for // `executeJson`'s own copy of `ModelTraits::typeId()`). double const idLengthCost = longExecuteJson - shortExecuteJson; diff --git a/tests/bench/bench_dispatch_latency.cpp b/tests/bench/bench_dispatch_latency.cpp index 8da9903ca..6ac8aa388 100644 --- a/tests/bench/bench_dispatch_latency.cpp +++ b/tests/bench/bench_dispatch_latency.cpp @@ -11,9 +11,9 @@ // regression gate via CHECK on p99 latency and minimum concurrency-1 // throughput. // -// ── What this file measures, and what it used to measure (morph#687) ──────── +// ── What this file measures, and what it must not ─────────────────────────── // -// **The serial phase used to report the test harness's polling step, not a +// **The serial phase must not report the test harness's polling step, not a // round trip.** It waited on each reply with `morph::testing::WaitReply`, // whose `await()` calls `waitUntil`, which does // `std::this_thread::sleep_for(5ms)` between predicate checks. So an idle @@ -25,8 +25,8 @@ // 16-way loaded p50 0.0167 ms (min 0.0166, max 0.0216) // // A 302x swing in the headline figure, selected by machine load -- the same -// shape of defect morph#687 recorded for `morph_bench_alloc`, and larger. -// Neither mode was the dispatch latency: the same idle processes reported +// shape of defect `morph_bench_alloc` is subject to, and larger. +// Neither mode is the dispatch latency: the same idle processes reported // ~176k executes/sec at concurrency 1, i.e. a round trip of about 5.7 us, // three orders of magnitude below the 5074 us the latency phase printed. // @@ -34,18 +34,18 @@ // so what is timed is `handle()` to reply and one thread wakeup. The // replacement is local to this file on purpose: `waitUntil` has 464 call sites // and changing it is not this benchmark's business, so what the rest of them -// inherit is morph#708 rather than a fix folded in here. The drain at +// inherit is a separate change rather than a fix folded in here. The drain at // the end of each throughput window is blocking for the same reason: it is // inside the window's own elapsed time, so a 5 ms polling tail was being // charged to the throughput figure. // // ── Why it reports a distribution rather than a figure ────────────────────── // -// morph#687's other half: a cited number taken from one process is one sample. -// `morph_bench_alloc` answers that by pinning the race it was subject to, and +// A cited number taken from one process is one sample. +// `morph_bench_alloc` answers that by pinning the race it is subject to, and // an allocation count then comes out exact. A wall-clock figure has no such // regime to pin -- contention is not a mode, it is a tax -- so this benchmark -// takes the other option morph#687 names and reports a distribution: it runs +// takes the other option and reports a distribution: it runs // `MORPH_BENCH_TRIALS` trials and prints the best, median and worst of each // percentile and each throughput point, with every trial written to the JSON // artifact. @@ -160,9 +160,9 @@ double medianOf(std::vector values) { return values[values.size() / 2]; } -// ── Every figure carries the load it was taken under (morph#707) ───────────── +// ── Every figure carries the load it was taken under ──────────────────────── // -// morph#710's own sweeps put the same binary 28x apart on throughput between +// Load sweeps put the same binary 28x apart on throughput between // an idle box and a 16-way loaded one. A benchmark number without the load it // was measured at is therefore not comparable with another one, and most of // the confusion this instrument has caused came from comparing two such @@ -191,7 +191,7 @@ double loadAverage1m() { return -1.0; } -// ── The injectable regression (morph#707, AGENTS.md's non-vacuity rule) ────── +// ── The injectable regression (AGENTS.md's non-vacuity rule) ──────────────── // // "A ceiling derived from a distribution but never fired is still unproven." // The same is true of a ceiling that was never derived: nobody has ever shown @@ -213,14 +213,13 @@ std::chrono::microseconds injectedSpin() { return kSpin; } -// ── The cross-process ledger (morph#687's remaining half) ──────────────────── +// ── The cross-process ledger ──────────────────────────────────────────────── // -// morph#710 gave this benchmark a distribution over trials *within* one +// The trial loop above gives a distribution over trials *within* one // process. That substantially mitigates the spread -- 302x down to about 3x in // the common cases -- but it does not **record** it: a single process still -// prints one triple and cannot say where that triple sits among others. That -// is why morph#687's close condition ("reports a distribution over processes") -// was never actually met, and it is the half that is met here. +// prints one triple and cannot say where that triple sits among others. A +// distribution over *processes* is what this ledger adds. // // N runs of this binary accumulate into one JSON-lines file with no // orchestration at all: @@ -495,21 +494,22 @@ TEST_CASE("bench: RemoteServer dispatch throughput and latency", "[bench]") { // for anything finer -- that is what the distribution is for, and it is // why this file now writes one. // - // ── morph#707: the cheaper alternative, weighed, and the answer ───────── + // ── The cheaper alternative, weighed, and the answer ─────────────────── // - // morph#707 asks that an allocation gate be weighed before any wall-clock - // ceiling is tightened, on the grounds that allocations are deterministic + // An allocation gate is what to weigh before tightening any wall-clock + // ceiling, since allocations are deterministic // and load-independent. **That gate already exists and already runs.** // `tests/bench/CMakeLists.txt` registers `bench.alloc_budget`, which fails // the build above 9.0 heap allocations per local round trip against a // figure measured at exactly 8.06 on every one of 84 processes across - // three toolchains, idle and oversubscribed alike (morph#700, morph#743, - // morph#572). Its margin is one allocation -- "the smallest regression + // three toolchains, idle and oversubscribed alike. Its margin is one + // allocation -- "the smallest regression // worth a red build" -- and no wall-clock constant on any host is within // three orders of magnitude of that resolution. // - // So the property morph#707 worried was ungated ("dispatch does not get - // more expensive") **is** gated, tightly, by a different instrument. What + // So the property these loose ceilings look like they leave ungated + // ("dispatch does not get more expensive") **is** gated, tightly, by a + // different instrument. What // the two constants below gate is the residue: a regression that costs // time without costing allocations -- a spin, a syscall, a lock held // longer, a sleep. That is a real class, and it is the only class these @@ -517,11 +517,11 @@ TEST_CASE("bench: RemoteServer dispatch throughput and latency", "[bench]") { // // They stay at 50 ms and 500/sec, and this is a decision rather than // an omission. Tightening them needs the CI runner characterised rather - // than guessed at, and this lane could not characterise it: these figures - // come from a 12-core workstation, which is the configuration morph#707 - // explicitly says is the misleading one. Setting them from this box was - // tried once already and rejected on measurement -- morph#710's 20000/sec - // turned 3 of 20 Debug-under-load processes red -- and guessing a second + // than guessed at, and it has not been: these figures + // come from a 12-core workstation, which is the misleading configuration to + // set a CI ceiling from. Setting them from this box has been + // tried and rejected on measurement -- a 20000/sec floor + // turns 3 of 20 Debug-under-load processes red -- and guessing a second // time from the same box would be the same mistake with a different // number. **What is added instead is the evidence a future tightening // needs**: the cross-process ledger below, so a candidate ceiling can be @@ -546,8 +546,8 @@ TEST_CASE("bench: RemoteServer dispatch throughput and latency", "[bench]") { // shown to. And the size they fire at is the point: the baseline round // trip is about 5.9 us, so the **throughput floor -- the tighter of the // two -- first speaks at roughly a 340x regression**, and the p99 ceiling - // at roughly 8500x. That is the "~800x" morph#707 estimated, confirmed by - // measurement and if anything understated for the p99 half. + // at roughly 8500x -- so the order of magnitude a loose ceiling costs is + // measured here rather than estimated. // // So these are gross-failure detectors, they are now documented as such // with the number attached, and a reader who wants resolution should watch @@ -717,7 +717,7 @@ TEST_CASE("bench: RemoteServer dispatch throughput and latency", "[bench]") { } artifact << "]}"; - // ── The cross-process ledger (morph#687, morph#707 step 2) ────────────── + // ── The cross-process ledger ──────────────────────────────────────────── // // Appended after the per-process artifact is complete, so a row exists // only for a run that produced a full set of figures. `load_1m` and diff --git a/tests/compile_checks/client_only_facade_no_model_header.cpp b/tests/compile_checks/client_only_facade_no_model_header.cpp index 99c089a9d..d4c712bda 100644 --- a/tests/compile_checks/client_only_facade_no_model_header.cpp +++ b/tests/compile_checks/client_only_facade_no_model_header.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// Compile/link-check fixture for issue #61: proves that a MORPH_CLIENT_ONLY +// Compile/link-check fixture: proves that a MORPH_CLIENT_ONLY // client's translation unit never needs to see a model's complete class body // (and therefore never needs the header for a persistence mixin the model // happens to inherit from/embed) merely to dispatch one of its actions. diff --git a/tests/compile_checks/demote_interface_includes_selftest.cmake b/tests/compile_checks/demote_interface_includes_selftest.cmake index d12a9cf95..8b4436021 100644 --- a/tests/compile_checks/demote_interface_includes_selftest.cmake +++ b/tests/compile_checks/demote_interface_includes_selftest.cmake @@ -2,7 +2,7 @@ # # Configure-time self-test for # cmake/morph_demote_interface_includes.cmake's -# morph_demote_interface_includes_to_system() (morph#438). +# morph_demote_interface_includes_to_system(). # # ── Why a synthetic fixture, and not an assertion about Lightweight ────────── # @@ -23,12 +23,12 @@ # full. This fails if the function body is removed. # # It does NOT prove the real Lightweight target is handed a real `-I` on the -# affected machine -- that is macOS-only and is item 7 of morph#438's +# affected machine -- that is macOS-only and is item 7 of this helper's own # acceptance criteria, for whoever has the hardware. This file proves the # transform; that proves the input. # ── The fixture ────────────────────────────────────────────────────────────── -# Every case morph#438 names, in one list: +# Every case the helper has to handle, in one list: # -I/fixture/joined joined form, one element # -I;/fixture/separated separated form, two elements # -Ifixture/relative joined form with a relative directory @@ -69,7 +69,7 @@ if(NOT "${_morph_selftest_opts}" STREQUAL "${_morph_selftest_want_opts}") " expected: ${_morph_selftest_want_opts}\n" "Every non-`-I` option must survive unchanged and in order, and every " "`-I` (joined or separated) must be gone. See " - "cmake/morph_demote_interface_includes.cmake and morph#438.") + "cmake/morph_demote_interface_includes.cmake.") endif() if(NOT "${_morph_selftest_sys}" STREQUAL "${_morph_selftest_want_sys}") @@ -80,8 +80,7 @@ if(NOT "${_morph_selftest_sys}" STREQUAL "${_morph_selftest_want_sys}") " expected: ${_morph_selftest_want_sys}\n" "This is the property morph's three existing SYSTEM demotions read, and " "the one an `-isystem` on the command line comes from -- the whole point " - "of the move. See cmake/morph_demote_interface_includes.cmake and " - "morph#438.") + "of the move. See cmake/morph_demote_interface_includes.cmake.") endif() # ── Idempotence ────────────────────────────────────────────────────────────── diff --git a/tests/compile_checks/forms_dag_budget.cmake b/tests/compile_checks/forms_dag_budget.cmake index 2c8db1bed..c3be83769 100644 --- a/tests/compile_checks/forms_dag_budget.cmake +++ b/tests/compile_checks/forms_dag_budget.cmake @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # -# The Part B regression guard for morph#573: compile-time sensitivity of +# The regression guard for route-count sensitivity: compile-time sensitivity of # `morph::forms::schemaJson()` to *route count* through a nested-aggregate # type graph. # @@ -22,14 +22,14 @@ # writer, leaving route-count sensitivity alone. # # Measured, g++ 16.2.1, `-std=c++23 -fsyntax-only`, CPU seconds, best of 2 on a -# shared machine (so treat these as upper bounds, and see morph#573 for the +# shared machine (so treat these as upper bounds, and see the probe for the # spread): # # revision control fixture ratio # master @ a9cb5649 (before) 2.70 26.83 9.9 -# with morph#573 step 3 2.69 2.98 1.11 +# with a depth counter 2.69 2.98 1.11 # -# morph#703 then removed the depth NTTP entirely, so instantiations are keyed on +# Carrying nothing at all keys instantiations on # the type alone rather than on a (type, depth) pair. This guard is unchanged by # that on purpose: it asserts a ratio and does not care how the ratio is # achieved, which is what let the second fix be judged by the instrument built @@ -37,7 +37,7 @@ # # The default threshold of 300% therefore sits roughly 2.7x above the fixed # ratio and 3.3x below the unfixed one. **This was verified by reverting step 3 -# and watching this case fail** — see the PR for morph#573 for the output. A +# and watching this case fail**. A # guard that passes with the fix reverted is not evidence (AGENTS.md, "Verify # rather than assert"), and this one does not. # @@ -161,7 +161,7 @@ message(STATUS if(_fixture_ms GREATER _budget_ms) message(FATAL_ERROR - "forms_dag_budget.cmake: schema generation is route-count sensitive again (morph#573, Part B).\n" + "forms_dag_budget.cmake: schema generation is route-count sensitive again.\n" " control (one route per node): ${_control_ms} ms\n" " fixture (6561 routes): ${_fixture_ms} ms = ${_ratio_percent}% of control\n" " budget: ${MORPH_DAG_MAX_RATIO_PERCENT}% of control (${_budget_ms} ms)\n" diff --git a/tests/compile_checks/forms_dag_probe.cpp b/tests/compile_checks/forms_dag_probe.cpp index e7ea7dfd1..516c33042 100644 --- a/tests/compile_checks/forms_dag_probe.cpp +++ b/tests/compile_checks/forms_dag_probe.cpp @@ -1,15 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 // -// The Part B regression fixture for morph#573: a domain model that is a *DAG* +// The regression fixture for route-count sensitivity: a domain model that is a *DAG* // rather than a tree, compiled through `morph::forms::schemaJson()`. // -// Before morph#573 step 3, the nested-aggregate recursion carried the ancestor +// A nested-aggregate recursion that carries the ancestor // chain as a template parameter pack, so `annotateNestedAggregate` was a distinct instantiation **per distinct root-to-node // route** through the type graph. A tree has one route per node; a DAG has as // many as the graph has paths, and that count grows exponentially in the // graph's depth. The recursion then carried a depth counter instead, capping -// instantiations at one per (type, depth) pair; morph#703 removed that too, +// instantiations at one per (type, depth) pair, and carrying nothing at all +// keys them on the type alone, // leaving one instantiation per type. This fixture measures neither directly // -- it measures route sensitivity, which both changes remove. // @@ -36,7 +37,7 @@ // // revision control (tree) fixture (DAG) ratio // master @ a9cb5649 (before) 2.70 26.83 9.9 -// with morph#573 step 3 2.69 2.98 1.11 +// with a depth counter 2.69 2.98 1.11 // // The spread between runs reached 50% on that machine, which is the other // reason the budget script asserts the ratio rather than either absolute @@ -100,7 +101,7 @@ MORPH_FORMS_DAG_PROBE_LEVEL(8, 7) /// The action type the schema is generated for: eight nested-aggregate levels /// below it. That was inside `morph::forms::detail::kMaxNestDepth` when this /// fixture was written, on purpose — it exists to stress route count and must -/// not double as a test of the depth limit. morph#703 removed the limit, so +/// not double as a test of a depth limit. There is no such limit, so /// the depth is now only a shape choice; the fixture is left unchanged so its /// numbers stay comparable with the ones quoted above. using RootAction = A8_0; diff --git a/tests/compile_checks/journal_skew_probe.cpp b/tests/compile_checks/journal_skew_probe.cpp index 88c9c6bdd..fdaa004e6 100644 --- a/tests/compile_checks/journal_skew_probe.cpp +++ b/tests/compile_checks/journal_skew_probe.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// Two-binary journal-path skew probe (issue #246). +// Two-binary journal-path skew probe. // // `examples/lims/README.md` asks for the executable form of the journal's // data-at-rest contract: an *old* build records a journal, a *new* build reads @@ -18,7 +18,7 @@ // the model-owning registrars, so a client-only binary cannot execute a model // and therefore cannot journal anything — it is the right gate for the // *wire*-path skew test, which needs a per-action fingerprint exchanged at -// `hello` before there is anything to assert against, and which issue #207 +// `hello` before there is anything to assert against, and which nothing // tracks. This probe covers the journal path, which ships today. // // Three actions, chosen for the three distinct answers: diff --git a/tests/forms_rule_corpus.hpp b/tests/forms_rule_corpus.hpp index 63641028c..7ec68f096 100644 --- a/tests/forms_rule_corpus.hpp +++ b/tests/forms_rule_corpus.hpp @@ -10,7 +10,7 @@ /// /// `x-rules` has two evaluators — `morph::forms::allRulesSatisfied` here, and /// a JavaScript reimplementation in `src/qt/forms/qml/DynamicForm.qml` — and -/// nothing structural pinned them to each other (morph#176). A hand-mirrored +/// and nothing structural pins them to each other. A hand-mirrored /// pair of test files does not fix that: adding a rule kind to one side leaves /// the other silently untested, which is how `atLeastOneOf` and /// `mutuallyExclusive` could be disabled client-side with the renderer suite @@ -99,7 +99,7 @@ struct RcNotEngaged { morph::forms::requiredWhen(&RcNotEngaged::note, morph::forms::notEngaged(&RcNotEngaged::amount))); }; -/// @brief `equals` against a `bool` literal — divergence (a) of morph#176. +/// @brief `equals` against a `bool` literal — divergence (a). struct RcEqualsBool { std::optional flag; ///< Compared against the literal `true`. std::optional note; ///< Required while `flag` is true. diff --git a/tests/fuzz/CMakeLists.txt b/tests/fuzz/CMakeLists.txt index ce3cac2d3..0832240ed 100644 --- a/tests/fuzz/CMakeLists.txt +++ b/tests/fuzz/CMakeLists.txt @@ -5,7 +5,7 @@ # # "Clang" here excludes "AppleClang" on purpose — unlike the warning gate in # cmake/compiler_options.cmake, which had to be widened to cover both (issue -# #298). Xcode's toolchain accepts -fsanitize=fuzzer on the compile line but +# above). Xcode's toolchain accepts -fsanitize=fuzzer on the compile line but # ships no libFuzzer runtime, so the link fails with # "library 'libclang_rt.fuzzer_osx.a' not found". Failing at configure time # with the message below is the better error. diff --git a/tests/lint/automoc_includes/invalid/long_climb/moc_budget_presenter.cpp b/tests/lint/automoc_includes/invalid/long_climb/moc_budget_presenter.cpp index 4136ec367..f2e072e07 100644 --- a/tests/lint/automoc_includes/invalid/long_climb/moc_budget_presenter.cpp +++ b/tests/lint/automoc_includes/invalid/long_climb/moc_budget_presenter.cpp @@ -1,4 +1,4 @@ -// Fixture for scripts/check_automoc_includes.sh: verbatim shape of issue #372 +// Fixture for scripts/check_automoc_includes.sh: verbatim shape of the defect // -- moc's default include, climbing six levels out of the build tree back to // the source root. Resolved against every -I entry as well, which is how it // reaches a same-named header in a different checkout. diff --git a/tests/net/CMakeLists.txt b/tests/net/CMakeLists.txt index dc569761e..c312de9e6 100644 --- a/tests/net/CMakeLists.txt +++ b/tests/net/CMakeLists.txt @@ -25,7 +25,7 @@ if(AF_COVERAGE) endif() include(Catch) -# LABELS "net" is what makes `ctest -L net` mean anything (morph#771). +# LABELS "net" is what makes `ctest -L net` mean anything. # # Catch2's own tags -- `[net][tcp]`, `[net][handshake][socket]` -- are not # ctest labels: no `catch_discover_tests` call in this repository passes diff --git a/tests/net/test_handshake_over_socket.cpp b/tests/net/test_handshake_over_socket.cpp index d81715cff..52406c900 100644 --- a/tests/net/test_handshake_over_socket.cpp +++ b/tests/net/test_handshake_over_socket.cpp @@ -14,7 +14,7 @@ using morph::net::detail::ParsedWsUrl; using morph::net::detail::TcpSocket; -// ── The four blocking accept()s here carry no deadline, and why (morph#772) ── +// ── The four blocking accept()s here carry no deadline, and why ───────────── // // Every one of them runs on the server thread this file spawns, while the // connection that satisfies it is made by the *main* thread immediately after, @@ -23,8 +23,8 @@ using morph::net::detail::TcpSocket; // with that connection already established against this listener: loopback, // a 64-deep backlog, one consumer. // -// That is the opposite shape from the site morph#773 had to bound -// (`FakeWsServer::acceptAndHandshake()`, a *main-thread* `accept()` waiting on +// That is the opposite shape from the one site in these tests that does need a +// bound (`FakeWsServer::acceptAndHandshake()`, a *main-thread* `accept()` waiting on // the io thread of the component under test), and it is the reason a deadline // here would be a path nothing can take. An untakeable timeout path is worse // than none: it reads as a hazard that was found and handled. @@ -36,7 +36,7 @@ using morph::net::detail::TcpSocket; // `tcp_socket.hpp`'s `accept()` contract, not measured on these four sites. // // A `connect()` that throws aborts via `~std::thread` instead of hanging; -// the error it discards on the way is morph#781. +// the error it discards on the way is reported nowhere. TEST_CASE("performClientHandshake/performServerHandshake complete over a real socket", "[net][handshake][socket]") { auto listener = TcpSocket::listen(0); diff --git a/tests/net/test_socket_backend.cpp b/tests/net/test_socket_backend.cpp index c4d535d66..3579ff771 100644 --- a/tests/net/test_socket_backend.cpp +++ b/tests/net/test_socket_backend.cpp @@ -170,8 +170,8 @@ class FakeWsServer { // concurrently on its own io thread by the time this is called, so this // normally returns promptly -- but "normally" is not a bound. A blocking // `accept()` here parks the *main test thread* with nothing else in the - // process able to satisfy it if the client never connects, which is how - // morph#559 saw a net test hang indefinitely in `accept()` under + // process able to satisfy it if the client never connects, which is how a + // net test hangs indefinitely in `accept()` under // concurrent machine load: a hang costs a whole CI job, where a failure // costs one line. Every test in this file goes through here, so bounding // it once bounds all of them. @@ -229,7 +229,7 @@ class FakeWsServer { // Shrinks the receive buffer to make a subsequent large write from the // peer fill the kernel's TCP window quickly. Combined with never calling // recv() again, this reliably blocks the peer's `send()` -- for tests - // exercising `SO_SNDTIMEO` (morph#536) without needing a multi-megabyte + // exercising `SO_SNDTIMEO` without needing a multi-megabyte // payload or a multi-second wait. void stopReadingWithTinyReceiveBuffer() { int const tinyBuf = 2048; @@ -459,7 +459,7 @@ TEST_CASE("SocketBackend: a plain handler keeps its own instance over the wire", TEST_CASE("SocketBackend: a fire-and-forget deregister's reply is not consumed by a parked sync call", "[net][socket_backend]") { - // Regression coverage for morph#454 -- morph#65 reintroduced in this + // Regression coverage for reply cross-talk in this // transport. `deregisterModel` sends fire-and-forget, but the server still // answers it with an `ok` (remote.hpp's deregister branch), and that reply // carries whatever `callId` the request had. With `callId == 0` -- the @@ -655,8 +655,8 @@ TEST_CASE("SocketBackend: attachModel with an empty primary and current==0 regis TEST_CASE("SocketBackend: attachModel's empty-primary path deregisters the instance being given up", "[net][socket_backend]") { - // Also exercises (and documents) a known cross-talk hazard, filed as - // morph#454: deregisterModel()'s fire-and-forget server acknowledgment + // Also exercises (and documents) a known cross-talk hazard: + // deregisterModel()'s fire-and-forget server acknowledgment // and a synchronous control call's reply both travel as callId == 0, so // a synchronous call issued immediately after a deregister -- exactly // what this branch does (`deregisterModel(current)` followed immediately @@ -683,19 +683,19 @@ TEST_CASE("SocketBackend: attachModel's empty-primary path deregisters the insta // current != 0, empty primary: the private-handoff path -- give up the // shared instance for a fresh private one. Must not throw or hang even - // though the reply it decodes may be the deregister's stray ack (#454). + // though the reply it decodes may be the deregister's stray ack. REQUIRE_NOTHROW( backend.attachModel("SbCounterModel", nullptr, morph::backend::detail::InstanceIdentity{}, shared)); // Let the real (now-orphaned) register reply this call's sendSync did not // consume finish draining before starting a fresh synchronous call below // -- otherwise it could itself be misdelivered to that call by the same - // #454 hazard, corrupting *this* test's own verification step. + // cross-talk hazard, corrupting *this* test's own verification step. std::this_thread::sleep_for(std::chrono::milliseconds{100}); // The instance held under "handoff-key" must be released -- confirms // deregisterModel(current) genuinely ran (the server-side effect, which - // #454 does not touch). + // the cross-talk hazard does not touch). bool released = false; for (int i = 0; i < 100 && !released; ++i) { auto keys = backend.listInstances("SbCounterModel"); @@ -817,7 +817,7 @@ TEST_CASE("SocketBackend: attachModel on a disconnected socket throws instead of TEST_CASE("SocketBackend: ~SocketBackend does not hang against a peer that stalls the handshake", "[net][socket_backend]") { - // Regression coverage for morph#535. A TCP listener need never call + // A TCP listener need never call // accept() for a connecting client's connect() to succeed -- the kernel // completes the three-way handshake into the listen backlog on its own. // That gives a peer that is connected at the TCP level but writes @@ -1068,7 +1068,7 @@ TEST_CASE("SocketBackend: execute resolves with an exception when the server's o TEST_CASE("SocketBackend: a send blocked past sendTimeout tears the connection down instead of desyncing it", "[net][socket_backend][fault-injection]") { - // Regression coverage for morph#536. `TcpSocket::sendAll` can throw + // `TcpSocket::sendAll` can throw // having already written part of a frame -- `SO_SNDTIMEO` firing // mid-send is exactly this, reachable whenever a peer stops reading. The // old `sendFrame` swallowed that exception without marking the @@ -1317,10 +1317,10 @@ TEST_CASE("SocketBackend: execute() racing a disconnect never leaves a Completio // would also produce. // // Deliberately NOT a heavier stress shape (many threads x many calls). - // That shape belongs to morph#449 -- a stranded execute-ordering ticket - // when a connection with several executes in flight drops -- whose own + // That shape belongs to the stranded-execute-ticket case -- a connection + // with several executes in flight dropping -- whose own // hang would masquerade as a failure of *this* fix instead of the - // ticket-ordering issue it actually is. morph#449 is fixed (see + // ticket-ordering problem it actually is. That case is covered (see // `ExecuteOrderGate::release` in core/detail/execute_order_gate.hpp, // extracted out of remote.hpp's own `releaseExecuteTicket` after this // fix landed), and its own stress-shaped regression test is "many @@ -1451,8 +1451,7 @@ TEST_CASE("SocketBackend: sendFrame-triggering calls racing a hard disconnect ne TEST_CASE("SocketBackend: executeTimeout surfaces as backend::TimeoutError, not a generic runtime_error", "[net][socket_backend][timeout]") { // Regression coverage for dispatchIncomingEnvelope's env.message == - // wire::kExecuteTimeoutMessage branch (added alongside the SqliteOfflineQueue - // and bridge.hpp fixes in #447): a server-side LimitPolicy::executeTimeout + // wire::kExecuteTimeoutMessage branch: a server-side LimitPolicy::executeTimeout // reply must resolve the Completion with backend::TimeoutError specifically, // the same type QtWebSocketBackend/SimulatedRemoteBackend give callers for // this case -- not the generic std::runtime_error the `else` branch below it @@ -1651,7 +1650,8 @@ TEST_CASE("SocketBackend: a reconnect handler throwing a non-std::exception leav // NOLINTNEXTLINE(readability-function-cognitive-complexity) TEST_CASE("SocketBackend: many concurrent executes racing a disconnect leave no stranded execute ticket", "[net][socket_backend][disconnect]") { - // Regression coverage for morph#449 at the transport level; the mechanism + // Regression coverage for a stranded execute ticket at the transport + // level; the mechanism // itself is pinned deterministically by // tests/test_remote_execute_ordering.cpp's "an execute rejected out of // ticket order..." case. This is the shape that actually found it, kept @@ -1685,8 +1685,8 @@ TEST_CASE("SocketBackend: many concurrent executes racing a disconnect leave no // // It is deliberately the "heavier stress shape" the sibling // "execute() racing a disconnect never leaves a Completion unresolved" - // case above avoids: kept separate so a morph#449 regression fails here, - // where it is diagnosed, rather than masquerading as a failure of that + // case above avoids: kept separate so a ticket-ordering regression fails + // here, where it is diagnosed, rather than masquerading as a failure of that // test's own TOCTOU fix. for (int iter = 0; iter < 3; ++iter) { morph::exec::ThreadPoolExecutor serverPool{4}; @@ -1743,7 +1743,7 @@ TEST_CASE("SocketBackend: many concurrent executes racing a disconnect leave no } } -// ── The structural registration surface (morph#569) ────────────────────────── +// ── The structural registration surface ───────────────────────────────────── // // `SocketBackend` overrides `bindModel`/`promoteModel` natively rather than // being wrapped in `SynchronousBackendAdapter`. The property that decides that @@ -1787,7 +1787,7 @@ TEST_CASE( // until that join completes. With the reverse order, TSan caught the I/O // thread still running -- and still able to call `post()` -- while this // executor's own destructor was tearing down its condition variable on the - // main thread (morph#586, data race in pthread_cond_destroy). + // main thread -- a data race in pthread_cond_destroy. morph::exec::MainThreadExecutor callerExec; morph::net::SocketBackend backend{"ws://127.0.0.1:" + std::to_string(static_cast(wsServer.port()))}; REQUIRE(backend.waitForConnected()); @@ -1886,7 +1886,7 @@ TEST_CASE("SocketBackend: a bindModel continuation does not run until the caller REQUIRE(wsServer.listen()); // Declared before `backend` -- see the identical comment on the first - // TEST_CASE in this file that needed it (morph#586, data race). + // TEST_CASE in this file that needed it: a data race on teardown. morph::exec::MainThreadExecutor callerExec; morph::net::SocketBackend backend{"ws://127.0.0.1:" + std::to_string(static_cast(wsServer.port()))}; REQUIRE(backend.waitForConnected()); @@ -1912,7 +1912,7 @@ TEST_CASE("SocketBackend: a bindModel continuation does not run until the caller // NOLINTNEXTLINE(readability-function-cognitive-complexity) TEST_CASE("SocketBackend: a bind settles while the synchronous control channel is still parked", "[net][socket_backend][registration-surface]") { - // This is the evidence behind morph#569's choice of a native override over + // This is the evidence behind the choice of a native override over // `SynchronousBackendAdapter`, and behind the deadlock claim in // docs/spec/core/backend.md. // @@ -1937,7 +1937,7 @@ TEST_CASE("SocketBackend: a bind settles while the synchronous control channel i cfg.reconnectEnabled = false; // Declared before `backend` -- see the identical comment on the first - // TEST_CASE in this file that needed it (morph#586, data race). + // TEST_CASE in this file that needed it: a data race on teardown. morph::exec::MainThreadExecutor callerExec; morph::net::SocketBackend backend{"ws://127.0.0.1:" + std::to_string(static_cast(fake.port())), cfg}; fake.acceptAndHandshake(); @@ -2031,7 +2031,7 @@ TEST_CASE("SocketBackend: several binds are in flight at once and are matched by cfg.reconnectEnabled = false; // Declared before `backend` -- see the identical comment on the first - // TEST_CASE in this file that needed it (morph#586, data race). + // TEST_CASE in this file that needed it: a data race on teardown. morph::exec::MainThreadExecutor callerExec; morph::net::SocketBackend backend{"ws://127.0.0.1:" + std::to_string(static_cast(fake.port())), cfg}; fake.acceptAndHandshake(); @@ -2129,7 +2129,7 @@ TEST_CASE("SocketBackend: a bind rejected by the server surfaces the server's ow REQUIRE(wsServer.listen()); // Declared before `backend` -- see the identical comment on the first - // TEST_CASE in this file that needed it (morph#586, data race). + // TEST_CASE in this file that needed it: a data race on teardown. morph::exec::MainThreadExecutor callerExec; morph::net::SocketBackend backend{"ws://127.0.0.1:" + std::to_string(static_cast(wsServer.port()))}; REQUIRE(backend.waitForConnected()); @@ -2152,7 +2152,7 @@ TEST_CASE("SocketBackend: a bind rejected by the server surfaces the server's ow TEST_CASE("SocketBackend: a reconnect handler can re-bind through the structural surface without waiting", "[net][socket_backend][disconnect][registration-surface]") { // The shape a reconnect handler takes once its caller is on the structural - // surface (morph#570): issue the bind, attach a continuation, return. The + // surface: issue the bind, attach a continuation, return. The // handler parks on nothing, so it has no reply to wait for and cannot hold // up whichever thread runs it. // @@ -2179,7 +2179,7 @@ TEST_CASE("SocketBackend: a reconnect handler can re-bind through the structural CHECK(fixture.backend->registerModel("SbEchoModel", nullptr).v != 0U); } -// ── contextKey on a private registration (morph#587) ───────────────────────── +// ── contextKey on a private registration ──────────────────────────────────── // // `RemoteServer::attachLogIfConfigured` (core/remote.hpp) returns *without // consulting its `LogProvider` at all* when the envelope's `contextKey` is diff --git a/tests/net/test_socket_server.cpp b/tests/net/test_socket_server.cpp index 52783b38a..339500a18 100644 --- a/tests/net/test_socket_server.cpp +++ b/tests/net/test_socket_server.cpp @@ -426,7 +426,7 @@ TEST_CASE("SocketServer: each client's models are reclaimed independently", "[ne // `SocketServer::close()` has to unblock its accept-loop thread before joining // it. Doing that by shutting down the *listening* socket works on Linux but is // a no-op on macOS/BSD kernels, where the join then never returns and every -// destructor of a listening server hangs forever (morph#437). The destruction +// destructor of a listening server hangs forever. The destruction // runs on its own thread here so the deadline can be observed and reported as a // failure instead of wedging the whole test binary until ctest's timeout. TEST_CASE("SocketServer: destruction completes promptly with the accept loop parked in accept()", @@ -462,7 +462,7 @@ TEST_CASE("SocketServer: destruction completes promptly with the accept loop par if (!morph::testing::waitUntil([destroyed] { return destroyed->load(); }, morph::testing::WaitBudget{std::chrono::seconds{5}})) { destroyer.detach(); // still parked in close(); the thread keeps `owned` alive on purpose - FAIL("SocketServer destruction did not complete within 5s: the accept loop was never unblocked (morph#437)"); + FAIL("SocketServer destruction did not complete within 5s: the accept loop was never unblocked"); } destroyer.join(); } @@ -686,7 +686,7 @@ TEST_CASE("SocketServer: acceptLoop's _closing checks observe a concurrent close // gives the accept loop several iterations' worth of draining to do, // widening the window during which a concurrent close() can land // mid-drain. Repeated, statistical -- matching this file's existing - // "destruction completes promptly" precedent for #437-class races. + // "destruction completes promptly" precedent for teardown races. // // A hang is the loop *stopping*, not the loop being slow -- and only the // first of those is a bug in SocketServer. What one iteration costs is set @@ -698,7 +698,7 @@ TEST_CASE("SocketServer: acceptLoop's _closing checks observe a concurrent close // ~44 s for the structurally identical burst test just below on a // GitHub-hosted runner. A wall-clock budget on the *total* therefore // measures the runner, not liveness, which is what made a 30 s one fire on - // a run where nothing was stuck (morph#476). + // a run where nothing was stuck. // // So: watch progress instead of the total. `progress` ticks once per // completed iteration; the loop is only declared hung when it stops @@ -1133,7 +1133,7 @@ TEST_CASE("SocketServer: sendControlFrame() swallows a send failure when the pee // cognitive-complexity threshold -- as in the sibling cases above. // NOLINTNEXTLINE(readability-function-cognitive-complexity) TEST_CASE("SocketServer: two threads calling close() concurrently do not both reach join()", "[net][socket_server]") { - // Regression coverage for morph#451. close() guarded itself with + // A `_closing` flag alone is not enough to serialise close(). Guarded with // // bool const wasAlreadyClosing = _closing.exchange(true); // if (wasAlreadyClosing && !_acceptThread.joinable()) { return; } @@ -1212,7 +1212,7 @@ TEST_CASE("SocketServer: two threads calling close() concurrently do not both re // cognitive-complexity threshold -- as in the sibling cases above. // NOLINTNEXTLINE(readability-function-cognitive-complexity) TEST_CASE("SocketServer: tearing down a parked accept loop finishes promptly", "[net][socket_server]") { - // Regression coverage for morph#437. close() used to interrupt the accept + // close() must not interrupt the accept // thread with `_listenSocket.shutdownBoth()` -- ::shutdown(fd, SHUT_RDWR) // on the *listening* socket -- and then join() it. That works only because // Linux chooses to kick a parked accept(2) when its listener is shut down. @@ -1289,7 +1289,7 @@ TEST_CASE("SocketServer: a parked accept loop survives repeated listen/close cyc } TEST_CASE("SocketServer::close() releases the listening port", "[net][socket_server]") { - // close() stops calling shutdownBoth() on the listener (morph#437), so it + // close() does not call shutdownBoth() on the listener, so it // has to drop the descriptor instead -- left open with no accept thread, // the kernel would keep completing handshakes into a backlog nobody drains // and a client would hang in the WebSocket Upgrade read rather than fail @@ -1344,7 +1344,7 @@ TEST_CASE("SocketServer: teardown racing a connecting client still finishes prom } } -// ── morph#498: finished connections must be reclaimed while the server runs ── +// ── Finished connections must be reclaimed while the server runs ──────────── // // `_clients` and `_clientThreads` were only ever pushed to in acceptLoop and // cleared in close(); nothing removed a connection whose clientLoop had diff --git a/tests/net/test_tcp_socket.cpp b/tests/net/test_tcp_socket.cpp index 3a6b5312b..5b72059b7 100644 --- a/tests/net/test_tcp_socket.cpp +++ b/tests/net/test_tcp_socket.cpp @@ -46,8 +46,8 @@ namespace { // highest fd open, and how many fds in that range are open at all. Same // technique as tests/net/test_socket_server.cpp's own `highestOpenFd()`, // with the count added -- `highest + 1 - open` is the size of the gap that -// `FdLimitClamp` exists to fill, and morph#559 asks for it to be *reported* -// rather than assumed away. +// `FdLimitClamp` exists to fill, and it is *reported* rather than assumed +// away. struct FdScan { int highest = -1; int open = 0; @@ -85,8 +85,8 @@ FdScan scanOpenFds(int limit) { // filling any such gap for real rather than assuming there isn't one. Only // once real exhaustion has been *observed* does the syscall under test run. // -// morph#559 reported this test failing under concurrent machine load and asked -// for the clamp to *say* what it measured rather than leave the next reader +// This test fails under concurrent machine load if the clamp does not hold, so +// the clamp *says* what it measured rather than leaving the next reader // guessing. `exhausted()` and `summary()` are that: every call site asserts // `exhausted()` before the syscall under test -- so a clamp that did not bite // fails on its own terms instead of being mistaken for a bug in `accept()` -- @@ -169,11 +169,11 @@ TEST_CASE("TcpSocket: listen on port 0 gets an OS-assigned port", "[net][tcp]") REQUIRE(listener.boundPort() != 0U); } -// ── Why the blocking accept()s below carry no deadline of their own (morph#772) ───── +// ── Why the blocking accept()s below carry no deadline of their own ───────── // -// morph#559 recorded a run parked indefinitely in `__accept` with nothing in -// the process able to satisfy it, and morph#773 bounded the one site that has -// that shape: `FakeWsServer::acceptAndHandshake()` in test_socket_backend.cpp, +// A run can park indefinitely in `__accept` with nothing in +// the process able to satisfy it. The one site in these tests with +// that shape is bounded: `FakeWsServer::acceptAndHandshake()` in test_socket_backend.cpp, // where the *main test thread* blocks in `accept()` while the only thing that // could satisfy it is the io thread of the `SocketBackend` under test -- i.e. // the very component whose failure to connect those tests exist to provoke. @@ -196,7 +196,8 @@ TEST_CASE("TcpSocket: listen on port 0 gets an OS-assigned port", "[net][tcp]") // // If (1) throws instead, the helper thread stays parked and `~std::thread` // calls `std::terminate` -- an abort in 0.09s that names the test, not a hang. -// The error it discards while doing so is morph#781, filed separately. +// The error it discards while doing so is not reported anywhere, which is a +// known cost of this shape. // // **Verification status.** The reasoning is inferred from reading plus // `tcp_socket.hpp`'s own `accept()` contract; it is not a measurement that @@ -212,8 +213,8 @@ TEST_CASE("TcpSocket: listen on port 0 gets an OS-assigned port", "[net][tcp]") // // That is also the backstop if this reasoning is ever wrong: `TIMEOUT 120` in // tests/net/CMakeLists.txt turns a park into a named ctest failure. It is a -// worse name than morph#773's (`accept()` timed out, versus "this test was -// slow") and six times the wall clock, which is why a site that genuinely can +// worse name than a bounded accept gives (`accept()` timed out, versus "this +// test was slow") and six times the wall clock, which is why a site that genuinely can // starve gets its own bound -- and why a site that cannot does not. // // Also bounded, and the two exceptions to the pattern above, both `accept()`ing @@ -221,7 +222,7 @@ TEST_CASE("TcpSocket: listen on port 0 gets an OS-assigned port", "[net][tcp]") // `accept()` on a 2s `poll()` over a *non-blocking* listener, where `accept()` // fails with EAGAIN rather than parking; and the EMFILE test gates its own on a // 5s `poll()` that both establishes the precondition it used to assume and -// bounds the wait (morph#773). +// bounds the wait. TEST_CASE("TcpSocket: connect/accept/send/recv round-trip", "[net][tcp]") { auto listener = TcpSocket::listen(0); @@ -258,7 +259,7 @@ TEST_CASE("TcpSocket: shutdownBoth unblocks a concurrent recvSome", "[net][tcp]" std::uint16_t const port = listener.boundPort(); TcpSocket serverSide; - // Bounded by shape rather than by a deadline: see the morph#772 note above the round-trip test. + // Bounded by shape rather than by a deadline: see the note above the round-trip test. std::thread acceptThread{[&] { serverSide = listener.accept(); }}; auto clientSide = TcpSocket::connect("127.0.0.1", port, std::chrono::milliseconds{2000}); acceptThread.join(); @@ -282,7 +283,7 @@ TEST_CASE("TcpSocket: recvSome returns 0 when the peer closes cleanly", "[net][t std::uint16_t const port = listener.boundPort(); TcpSocket serverSide; - // Bounded by shape rather than by a deadline: see the morph#772 note above the round-trip test. + // Bounded by shape rather than by a deadline: see the note above the round-trip test. std::thread acceptThread{[&] { serverSide = listener.accept(); }}; { auto clientSide = TcpSocket::connect("127.0.0.1", port, std::chrono::milliseconds{2000}); @@ -294,7 +295,7 @@ TEST_CASE("TcpSocket: recvSome returns 0 when the peer closes cleanly", "[net][t REQUIRE(got == 0U); } -// ── Non-blocking listener (morph#437) ─────────────────────────────────────── +// ── Non-blocking listener ─────────────────────────────────────────────────── TEST_CASE("TcpSocket: setNonBlocking makes an idle listener answer tryAccept with nullopt", "[net][tcp]") { // The property SocketServer's accept loop depends on: once poll() has @@ -349,11 +350,11 @@ TEST_CASE("TcpSocket: setNonBlocking reports failure on an empty socket", "[net] REQUIRE_FALSE(empty.setNonBlocking()); } -// ── Adopted sockets are blocking (morph#478) ──────────────────────────────── +// ── Adopted sockets are blocking ──────────────────────────────────────────── TEST_CASE("TcpSocket: adopting a non-blocking descriptor clears O_NONBLOCK", "[net][tcp]") { - // The regression control for morph#478, and the only test in this file that - // can fail on Linux because of it. + // The regression control for the adopted-descriptor rule, and the only test + // in this file that can fail on Linux because of it. // // macOS/BSD propagate a listener's O_NONBLOCK onto the sockets accept(2) // returns; Linux does not (measured with a standalone accept() probe here: @@ -385,7 +386,7 @@ TEST_CASE("TcpSocket: tryAccept hands back a blocking connection", "[net][tcp]") // EAGAIN as fatal, and the first thing clientLoop() does with an accepted // socket is performServerHandshake(), which reads before the client's // Upgrade bytes have necessarily arrived. A non-blocking accepted socket - // therefore fails every connection (morph#478). + // therefore fails every connection. // // Stated limit: on Linux this assertion also holds with the fix reverted, // because accept() here never produces a non-blocking socket to begin with. @@ -512,14 +513,14 @@ TEST_CASE("TcpSocket::listen: fails with EADDRINUSE when the port is already bou // `tcp_socket.hpp` formats its message on whichever thread hit the error, and // this subsystem spawns those threads itself, so the renderer has to be one // that two threads may call at once -- `std::error_category::message`, not -// `std::strerror` (morph#625). +// `std::strerror`. // -// What this case does not establish: that the previous `std::strerror` -// spelling was actually racing. glibc renders both spellings to the same -// bytes, so this assertion would have held before the change too. The evidence -// for the change is clang-tidy `concurrency-mt-unsafe` going from six findings -// in this header to none; this case is a standing guard on the message, not -// that measurement. +// What this case does not establish: that `std::strerror` would actually race +// here. glibc renders both spellings to the same bytes, so this assertion +// holds either way. The evidence for the choice is clang-tidy +// `concurrency-mt-unsafe`, which reports six findings in this header for the +// unsafe spelling and none for this one; this case is a standing guard on the +// message, not that measurement. TEST_CASE("TcpSocket::listen renders a bind() failure through std::system_category", "[net][tcp]") { auto first = TcpSocket::listen(0); std::uint16_t const port = first.boundPort(); @@ -545,8 +546,8 @@ TEST_CASE("TcpSocket::accept: throws when accept() itself runs out of file descr // SYN-ACK arrives, which is a different instant from the one at which the // listener's accept queue gains the child socket (the final ACK). On a // machine under load those two can separate, and the blocking `accept()` - // below would then park -- morph#559 saw a net test park in `accept()` - // indefinitely and take a whole run with it. Waiting for the listener to + // below would then park -- a net test parked in `accept()` + // indefinitely takes a whole run with it. Waiting for the listener to // actually report readable turns that into a bounded, named failure here, // and leaves `accept()` with a connection genuinely queued so the EMFILE // it hits is the one under test. @@ -567,7 +568,7 @@ TEST_CASE("TcpSocket::recvSome: throws for a real socket error distinct from ECO auto listener = TcpSocket::listen(0); std::uint16_t const port = listener.boundPort(); TcpSocket serverSide; - // Bounded by shape rather than by a deadline: see the morph#772 note above the round-trip test. + // Bounded by shape rather than by a deadline: see the note above the round-trip test. std::thread acceptThread{[&] { serverSide = listener.accept(); }}; auto clientSide = TcpSocket::connect("127.0.0.1", port, std::chrono::milliseconds{2000}); acceptThread.join(); @@ -597,7 +598,7 @@ TEST_CASE("TcpSocket::sendAll: throws when the peer resets the connection", "[ne std::uint16_t const port = listener.boundPort(); TcpSocket serverSide; - // Bounded by shape rather than by a deadline: see the morph#772 note above the round-trip test. + // Bounded by shape rather than by a deadline: see the note above the round-trip test. std::thread acceptThread{[&] { serverSide = listener.accept(); }}; { auto clientSide = TcpSocket::connect("127.0.0.1", port, std::chrono::milliseconds{2000}); @@ -630,7 +631,7 @@ TEST_CASE("TcpSocket::shutdownBoth: a safe no-op on an empty socket", "[net][tcp REQUIRE_FALSE(empty.valid()); } -// ── morph#506: a peer that stops reading must not park the sender forever ── +// ── A peer that stops reading must not park the sender forever ───────────── // // Once the kernel send buffer fills against a peer that never reads, a blocking // `::send` never returns -- and `sendAll` loops on it. `SocketBackend::sendFrame` @@ -647,7 +648,7 @@ TEST_CASE("TcpSocket: setSendTimeout bounds a send against a peer that never rea // Accepts and then does nothing at all -- never reads a byte. Held open for // the duration of the test so the connection stays established. TcpSocket serverSide; - // Bounded by shape rather than by a deadline: see the morph#772 note above the round-trip test. + // Bounded by shape rather than by a deadline: see the note above the round-trip test. std::thread acceptThread{[&] { serverSide = listener.accept(); }}; auto clientSide = TcpSocket::connect("127.0.0.1", port, std::chrono::milliseconds{2000}); acceptThread.join(); diff --git a/tests/net/test_ws_frame.cpp b/tests/net/test_ws_frame.cpp index 22c8e6190..c4c20cdc5 100644 --- a/tests/net/test_ws_frame.cpp +++ b/tests/net/test_ws_frame.cpp @@ -266,8 +266,8 @@ TEST_CASE("encodeWsFrame round-trips close/ping/pong opcodes", "[net][frame]") { } // ── RFC 6455 conformance: illegal frames a peer must not accept ──────────── -// morph#533 -- none of the following were rejected before this file's reader -// grew a role and these checks. +// A reader with no role and no length/opcode checks accepts every one of the +// following. TEST_CASE("WsFrameReader (server role) rejects an unmasked frame from a client", "[net][frame]") { WsFrameReader reader{/*expectMasked=*/true}; diff --git a/tests/net_qt_interop/CMakeLists.txt b/tests/net_qt_interop/CMakeLists.txt index e1153503b..95079fabd 100644 --- a/tests/net_qt_interop/CMakeLists.txt +++ b/tests/net_qt_interop/CMakeLists.txt @@ -13,13 +13,14 @@ target_link_libraries(morph_net_qt_interop_tests set_target_properties(morph_net_qt_interop_tests PROPERTIES AUTOMOC ON) apply_warnings(morph_net_qt_interop_tests) -# Was missing entirely: this suite drives morph::net against Qt's transport and -# was never instrumented (morph#403). +# Easy to leave out: this suite drives morph::net against Qt's transport, and +# an uninstrumented one contributes nothing to the coverage report while +# looking like it does. if(AF_COVERAGE) apply_coverage(morph_net_qt_interop_tests) endif() -# Likewise missing entirely (morph#542). This suite drives morph::net -- raw +# Likewise easy to leave out. This suite drives morph::net -- raw # sockets, an I/O thread and a hand-rolled frame reader -- against Qt's # transport, which is the part of the tree a sanitizer has most to say about. if(DEFINED AF_SANITIZER) diff --git a/tests/offline_queue_conformance.hpp b/tests/offline_queue_conformance.hpp index ba67f99fd..cee7e3a1a 100644 --- a/tests/offline_queue_conformance.hpp +++ b/tests/offline_queue_conformance.hpp @@ -192,8 +192,7 @@ inline void checkIdempotencyKeyContractAcrossReopen(const std::string& name, Key } /// @brief Asserts an implementation round-trips a NUL-bearing payload and -/// idempotency key intact, rather than truncating at the first `\0` -/// (morph#531). +/// idempotency key intact, rather than truncating at the first `\0`. /// /// `QueueItem::payload` is documented as an opaque string whose serialisation /// format is the caller's choice (JSON, binary-hex, plain text, ...), so a @@ -210,8 +209,8 @@ inline void checkIdempotencyKeyContractAcrossReopen(const std::string& name, Key /// the record *and* keeps the item in an in-memory map, and `drain()` /// serves that map, so without a reopen this check compares the values /// it just handed to `enqueue` against themselves and never executes the -/// on-disk encoder or decoder at all. That encoder is exactly what -/// morph#531 is about for the file backend: with no reopen, deleting +/// on-disk encoder or decoder at all. That encoder is where a NUL is +/// lost for the file backend: with no reopen, deleting /// `escape_control_characters` from `FileOfflineQueue`'s write options /// leaves this check green. // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) — `make` and `reopen` are the same type by nature; for the durable backends they are deliberately the *same* factory, so a swap is a no-op rather than a hazard diff --git a/tests/offline_sqlite/CMakeLists.txt b/tests/offline_sqlite/CMakeLists.txt index 3c29d7bff..6d5944cd6 100644 --- a/tests/offline_sqlite/CMakeLists.txt +++ b/tests/offline_sqlite/CMakeLists.txt @@ -17,12 +17,12 @@ apply_warnings(morph_offline_sqlite_tests) # Was missing entirely, so sqlite_offline_queue.hpp's dedicated suite was never # instrumented and the file's 57.04% -- the worst in the library -- was measured -# with its own tests absent from the report (morph#403). +# with its own tests absent from the report. if(AF_COVERAGE) apply_coverage(morph_offline_sqlite_tests) endif() -# Was missing for the same reason, with a sharper consequence (morph#542): the +# Easy to leave out for the same reason, with a sharper consequence: the # `clang-asan` leg turns MORPH_BUILD_OFFLINE_SQLITE on precisely because the # SQLite queue "is where the memory/threading/UB risk actually lives -- a C # API", then built this suite with zero sanitizer flags and ran it. The job was diff --git a/tests/offline_sqlite/test_sqlite_offline_queue.cpp b/tests/offline_sqlite/test_sqlite_offline_queue.cpp index cc548c15f..df7941382 100644 --- a/tests/offline_sqlite/test_sqlite_offline_queue.cpp +++ b/tests/offline_sqlite/test_sqlite_offline_queue.cpp @@ -166,7 +166,7 @@ TEST_CASE("morph::offline::SqliteOfflineQueue + SyncWorker: poison item dead-let removeDbFiles(dbPath); } -// ── Coverage: maxDepth / overflow policy (morph#112) ─────────────────────── +// ── Coverage: maxDepth / overflow policy ─────────────────────── TEST_CASE("morph::offline::SqliteOfflineQueue: enqueue at maxDepth throws OfflineQueueFullError", "[sqlite][overflow]") { @@ -296,8 +296,7 @@ TEST_CASE("morph::offline::SqliteOfflineQueue: the idempotency-key contract surv removeDbFiles(dbPath); } -TEST_CASE("morph::offline::SqliteOfflineQueue: a NUL-bearing payload and key round-trip intact (morph#531)", - "[sqlite]") { +TEST_CASE("morph::offline::SqliteOfflineQueue: a NUL-bearing payload and key round-trip intact", "[sqlite]") { auto dbPath = tempDbPath(); removeDbFiles(dbPath); auto const open = [&dbPath] { return std::make_unique(dbPath); }; @@ -305,7 +304,7 @@ TEST_CASE("morph::offline::SqliteOfflineQueue: a NUL-bearing payload and key rou removeDbFiles(dbPath); } -// ── setIdempotencyKey on a conflicting key (morph#249) ─────────────────────── +// ── setIdempotencyKey on a conflicting key ─────────────────────── // // The protected hook is reached only through the *base* default // `IOfflineQueue::enqueue(payload, key)`, which inserts first and stamps @@ -522,7 +521,7 @@ TEST_CASE( removeDbFiles(dbPath); } -// ── Durability PRAGMAs (morph#532) ─────────────────────────────────────── +// ── Durability PRAGMAs ─────────────────────────────────────── // // journal_mode=WAL, synchronous=FULL, and busy_timeout are all set at // construction. journal_mode is a persistent property of the database file @@ -532,7 +531,7 @@ TEST_CASE( // without shared-memory support silently falls back to `delete` mode // instead of erroring. -TEST_CASE("morph::offline::SqliteOfflineQueue: journal_mode=WAL persists and is verified at construction (morph#532)", +TEST_CASE("morph::offline::SqliteOfflineQueue: journal_mode=WAL persists and is verified at construction", "[sqlite]") { auto dbPath = tempDbPath(); removeDbFiles(dbPath); @@ -556,8 +555,7 @@ TEST_CASE("morph::offline::SqliteOfflineQueue: journal_mode=WAL persists and is removeDbFiles(dbPath); } -TEST_CASE("morph::offline::SqliteOfflineQueue: a journal_mode that is not WAL warns and keeps working (morph#532)", - "[sqlite]") { +TEST_CASE("morph::offline::SqliteOfflineQueue: a journal_mode that is not WAL warns and keeps working", "[sqlite]") { // An in-memory database always reports journal_mode "memory" regardless of // what is requested -- SQLite's own documented behavior (WAL needs shared // memory a `:memory:` database does not have), not a fault injected here. @@ -592,8 +590,7 @@ TEST_CASE("morph::offline::SqliteOfflineQueue: a journal_mode that is not WAL wa CHECK(queue->drain().empty()); } -TEST_CASE("morph::offline::SqliteOfflineQueue: a WAL database reports journalMode() == \"wal\" (morph#532)", - "[sqlite]") { +TEST_CASE("morph::offline::SqliteOfflineQueue: a WAL database reports journalMode() == \"wal\"", "[sqlite]") { // The other side of the case above: on an ordinary filesystem the read-back // must report `wal`, and must emit no warning. Without this, the warn path // above could pass while WAL silently never took anywhere. @@ -615,7 +612,7 @@ TEST_CASE("morph::offline::SqliteOfflineQueue: a WAL database reports journalMod TEST_CASE( "morph::offline::SqliteOfflineQueue: PRAGMA busy_timeout lets a write wait out a transient lock instead of " - "failing immediately (morph#532)", + "failing immediately", "[sqlite]") { auto dbPath = tempDbPath(); removeDbFiles(dbPath); @@ -686,7 +683,7 @@ TEST_CASE( removeDbFiles(dbPath); } -// ── Directory fsync (morph#532) ────────────────────────────────────────── +// ── Directory fsync ────────────────────────────────────────── // // `sqlite3_open()` creates `dbPath` (and, once journal_mode=WAL took, its // "-wal"/"-shm" siblings) on first use -- a fresh directory entry that @@ -697,10 +694,8 @@ TEST_CASE( // `FileActionLog`/`FileOfflineQueue` tests in test_action_log_phase2.cpp / // test_file_offline_queue.cpp. -TEST_CASE( - "morph::offline::SqliteOfflineQueue: construction syncs the containing directory after creating the file " - "(morph#532)", - "[sqlite]") { +TEST_CASE("morph::offline::SqliteOfflineQueue: construction syncs the containing directory after creating the file", + "[sqlite]") { auto dbPath = tempDbPath(); removeDbFiles(dbPath); std::vector syncedPaths; @@ -724,8 +719,7 @@ TEST_CASE( removeDbFiles(dbPath); } -TEST_CASE("morph::offline::SqliteOfflineQueue: an unsupported directory fsync warns instead of throwing (morph#532)", - "[sqlite]") { +TEST_CASE("morph::offline::SqliteOfflineQueue: an unsupported directory fsync warns instead of throwing", "[sqlite]") { // Same split as the file-backed queues: a directory fsync this platform or // mount cannot perform is a durability *ceiling*, not a failure, and must // not stop the database opening. kanban's enableOfflineQueue() builds one @@ -757,8 +751,7 @@ TEST_CASE("morph::offline::SqliteOfflineQueue: an unsupported directory fsync wa removeDbFiles(dbPath); } -TEST_CASE("morph::offline::SqliteOfflineQueue: Synchronous selects the level SQLite actually applies (morph#532)", - "[sqlite]") { +TEST_CASE("morph::offline::SqliteOfflineQueue: Synchronous selects the level SQLite actually applies", "[sqlite]") { // `full` is opt-in because it costs ~18x per mutation, and every mutation // here is its own commit -- so the parameter only earns its place if it // actually reaches SQLite. Asserted against the level read back from the @@ -791,7 +784,7 @@ TEST_CASE("morph::offline::SqliteOfflineQueue: Synchronous selects the level SQL TEST_CASE( "morph::offline::SqliteOfflineQueue: a failing directory fsync during construction throws and leaks no " - "connection (morph#532)", + "connection", "[sqlite]") { auto dbPath = tempDbPath(); removeDbFiles(dbPath); diff --git a/tests/oom_injector.cpp b/tests/oom_injector.cpp index 28b65d97a..5afcbfe58 100644 --- a/tests/oom_injector.cpp +++ b/tests/oom_injector.cpp @@ -12,8 +12,8 @@ // overloads are *compiled out* under either sanitizer, by the guard a few // lines below, rather than linked beside that runtime's. // -// Which means -- and an earlier version of this comment said the opposite, in -// a form confident enough to act on (morph#718) -- there is no link failure +// Which means -- against the obvious guess, which is confident enough to act +// on -- there is no link failure // and no "multiple definition of `operator new(unsigned long)'". Measured with // clang 22.1.8: `clang++ -std=c++23 -fsanitize=address // tests/oom_injector.cpp` links, and `nm -C --defined-only` finds eight @@ -28,9 +28,10 @@ // // ctest-level test exclusion is therefore exactly the remedy for it, and is // the one in use: .github/workflows/ci.yml excludes `OomInjector|morph#108` by -// name on the clang-asan and clang-tsan legs. With that filter bypassed, -// morph#719's lane measured six tests failing on each of the two legs. Deleting -// the exclusion on the strength of the old comment turns both legs red. +// name on the clang-asan and clang-tsan legs (one test's own name carries that +// token, which is why the filter names it). With that filter bypassed, six +// tests fail on each of the two legs, measured. Deleting the exclusion turns +// both legs red. // // The link failure the old comment described was presumably real before the // guard below existed -- it is why the guard exists -- and the comment was not diff --git a/tests/oom_injector.hpp b/tests/oom_injector.hpp index 08ab5dece..e1eeb4cf8 100644 --- a/tests/oom_injector.hpp +++ b/tests/oom_injector.hpp @@ -33,8 +33,8 @@ namespace morph::testkit { /// excluded consistently on every leg where the override cannot work. /// /// @par Why this exists -/// Several `catch (...)` blocks across the codebase (see -/// `LASTRADA-Software/morph#108`) only ever fire on `std::bad_alloc` from a +/// Several `catch (...)` blocks across the codebase only ever fire on +/// `std::bad_alloc` from a /// real allocation failure -- there is no other way into them. That is not /// portably reachable from a unit test without a seam: this class overrides /// the process-wide `operator new`/`operator new[]` (defined once in diff --git a/tests/qt/CMakeLists.txt b/tests/qt/CMakeLists.txt index c3c0846ab..d89d8497c 100644 --- a/tests/qt/CMakeLists.txt +++ b/tests/qt/CMakeLists.txt @@ -13,7 +13,7 @@ apply_warnings(qt_test_client) # AF_SANITIZER, for the same reason these two carry apply_coverage(): the # include/morph/qt code exercised across the process boundary only runs *here*, # in the spawned child, so a sanitizer applied to the parent alone watches the -# half of the conversation that is not being tested (morph#542). An instrumented +# half of the conversation that is not being tested. An instrumented # child reports on its own stderr and exits non-zero, which the parent's # process-exit assertions already surface. if(DEFINED AF_SANITIZER) @@ -27,7 +27,7 @@ endif() # instrumented child writes its own .profraw (LLVM_PROFILE_FILE's %p is per # process and the environment is inherited across the spawn), and llvm-cov can # only map it back through the binary that produced it -- see apply_coverage() -# in cmake/compiler_options.cmake, and morph#403 for what "instrumented but not +# in cmake/compiler_options.cmake, and check_coverage_objects.sh for what "instrumented but not # handed to llvm-cov" costs. if(AF_COVERAGE) apply_coverage(qt_test_server TEST) @@ -58,7 +58,7 @@ apply_warnings(morph_qt_tests) # Was missing entirely, so this suite was never instrumented and contributed # nothing to the coverage number while the CI coverage leg configured # -DMORPH_BUILD_QT=ON and ran it -- include/morph/qt showed 13 lines in the -# uploaded report (morph#403). +# uploaded report. if(AF_COVERAGE) apply_coverage(morph_qt_tests) endif() @@ -66,7 +66,7 @@ endif() # Was missing for the same reason and with the same consequence as the coverage # block above: morph is header-only, so this target compiled its own # uninstrumented copy of every include/morph/qt header while a job named for a -# sanitizer ran the suite and learned nothing from it (morph#542). +# sanitizer ran the suite and learned nothing from it. if(DEFINED AF_SANITIZER) apply_sanitizers(morph_qt_tests ${AF_SANITIZER}) endif() diff --git a/tests/qt/test_qt_executor_teardown.cpp b/tests/qt/test_qt_executor_teardown.cpp index be6810bfb..9c4d60b65 100644 --- a/tests/qt/test_qt_executor_teardown.cpp +++ b/tests/qt/test_qt_executor_teardown.cpp @@ -128,7 +128,7 @@ TEST_CASE("A nested Completion chain outliving its QtExecutor does not use it af // body, a dropped completion never decrements -- the queued lambda simply // releases its `shared_ptr` copy. The counter is therefore only meaningful // while its executor is alive, which is what those apps' "pump until false, -// then destroy" contract already requires of callers (morph#194). +// then destroy" contract already requires of callers. // // This is documented as deliberate rather than fixed: see the invariant on // `bookmarks::app::App::_fetchInFlight`. diff --git a/tests/qt/test_qt_websocket.cpp b/tests/qt/test_qt_websocket.cpp index c6386d6bf..9b8383ee7 100644 --- a/tests/qt/test_qt_websocket.cpp +++ b/tests/qt/test_qt_websocket.cpp @@ -325,9 +325,8 @@ TEST_CASE( REQUIRE(result.load() == 99); } -TEST_CASE( - "morph::qt::QtWebSocketBackend: Config-only constructor overload omits the dispatcher/registry pair (issue #55)", - "[qt][ws][issue55]") { +TEST_CASE("morph::qt::QtWebSocketBackend: Config-only constructor overload omits the dispatcher/registry pair", + "[qt][ws][issue55]") { // The seam under test: a caller who wants to set Config::asyncRegistrationEnabled // (or any other Config field) but has no reason to override the dispatcher/registry // pair must not have to name morph::model::detail::defaultDispatcher()/ @@ -366,8 +365,7 @@ TEST_CASE( #ifndef QT_NO_SSL TEST_CASE( - "morph::qt::QtWebSocketBackend: (serverUrl, tls, cfg) constructor overload omits the dispatcher/registry pair " - "(issue #55)", + "morph::qt::QtWebSocketBackend: (serverUrl, tls, cfg) constructor overload omits the dispatcher/registry pair", "[qt][ws][issue55]") { // The middle of the three constructor overloads: unlike the Config-only // one above, this one also lets a caller pass a `tls` configuration @@ -611,7 +609,7 @@ TEST_CASE("morph::qt::QtWebSocketBackend: a keyed bindModel on a never-connected CHECK(outcome.failure == "disconnected"); } -// ── The delivery thread, which is what morph#567 made structural ───────────── +// ── The delivery thread, which the surface makes structural ───────────────── // // This is the first *production* backend on the surface, so this is the first // test that the guarantee survives a real transport rather than a test double @@ -718,7 +716,7 @@ TEST_CASE( QUrl url{QString("ws://127.0.0.1:%1").arg(wsServer.port())}; // Deliberately do NOT call waitForConnected() before registering -- this is // exactly the ordering a single-threaded WASM client must use, since it can - // never block waiting for the connection to settle (see issue #54). + // never block waiting for the connection to settle. auto backendPtr = std::make_unique( url, morph::model::detail::defaultDispatcher(), morph::model::detail::defaultRegistry(), std::nullopt, morph::qt::QtWebSocketBackend::Config{.asyncRegistrationEnabled = true}); @@ -752,8 +750,8 @@ TEST_CASE( "morph::qt::QtWebSocketBackend: a fire-and-forget deregister's reply cannot be misrouted to a following " "synchronous register", "[qt][ws][issue65]") { - // Reproduces issue #65: deregisterModel() is fire-and-forget with callId - // 0, and registerModel()'s sendSync path also parks its nested event loop + // With callId 0, deregisterModel()'s fire-and-forget reply collides with + // registerModel()'s sendSync path, which parks its nested event loop // waiting for a callId==0 reply. Back to back on the same connection, // whichever callId==0 reply lands first used to be handed to the parked // sync loop -- if it was the deregister's stray "ok" (no modelId), the @@ -1745,8 +1743,8 @@ TEST_CASE("morph::qt::QtWebSocketServer: messagesPerSecond throttles a burst on // RemoteServer; the rest are refused at the transport. // // Refused is not the same as ignored: an over-budget frame is answered with - // an `err "rate limited"` addressed to its own callId (morph#225). Before - // that, it was dropped silently and the caller's Completion had nothing to + // an `err "rate limited"` addressed to its own callId. Dropped + // silently instead, the caller's Completion has nothing to // resolve it -- an execute that hung unless LimitPolicy::executeTimeout was // armed, which is off by default. std::atomic okReplies{0}; @@ -2462,9 +2460,9 @@ TEST_CASE("Process separation: TLS handshake works across processes", "[qt][wss] REQUIRE(runClient(url, {QStringLiteral("--tls")}) == 0); } -// Coverage for morph#594: a *private* registration over this transport must +// A *private* registration over this transport must // carry `contextKey` to the server, exactly as `SimulatedRemoteBackend` and -// `morph::net::SocketBackend` (morph#587) do. +// `morph::net::SocketBackend` do. // // The assertion is deliberately on the provider, not on the registration: // registration succeeded before this was fixed too. `RemoteServer:: @@ -2507,7 +2505,7 @@ TEST_CASE("morph::qt::QtWebSocketBackend: a private registration carries context // `asyncRegistrationEnabled` is unset, so this is `IBackend::bindModel`'s // default dispatching the empty-`primary`/zero-`current` shape to // `registerModelWithContext` — the path a `Bridge` over this backend - // takes, and the one morph#594 reported as dropping the key. + // takes, and the one that drops the key if it is not forwarded. morph::exec::detail::ModelId bound{}; backend .bindModel(morph::backend::detail::BindRequest{.typeId = "WsEchoModel", @@ -2553,17 +2551,17 @@ int main(int argc, char* argv[]) { return result; } -// ── morph#495: the non-blocking control paths must stamp the session too ── +// ── The non-blocking control paths must stamp the session too ── // -// The three optional non-blocking control verbs that existed then (the -// register-or-attach, re-point and promote twins, all removed by morph#571) -// each built their envelope and encoded it with no `env.session = _session`, +// A per-verb non-blocking twin (a register-or-attach, re-point or promote +// twin beside each synchronous verb) +// builds its own envelope, and one encoded with no `env.session = _session` // while all three synchronous counterparts stamped it. RemoteServer authenticates and // authorizes from env.session (remote.hpp: stampVerifiedPrincipal, and the // register/attach/assign authorization sites), so a client using the async path // -- which is the WASM path, and the only one a WASM main thread can use -- -// reached an authorizing server as an unauthenticated principal. Those three -// verbs are gone (morph#568); `sendControl` is now the single place a control +// reaches an authorizing server as an unauthenticated principal. There are no +// such twins here: `sendControl` is the single place a control // envelope is built, so the gap has one place left to reappear in -- and this // test still guards it. // diff --git a/tests/replay_ledger_conformance.hpp b/tests/replay_ledger_conformance.hpp index 9805f2457..8179abfb2 100644 --- a/tests/replay_ledger_conformance.hpp +++ b/tests/replay_ledger_conformance.hpp @@ -2,7 +2,7 @@ /// @file /// @brief Shared `IReplayLedger` conformance checks, run against every -/// implementation morph ships (morph#226). +/// implementation morph ships. /// /// Mirrors `tests/offline_queue_conformance.hpp`'s shape: a header of plain /// functions rather than `TEST_CASE`s, so a new implementation — including one diff --git a/tests/soak/CMakeLists.txt b/tests/soak/CMakeLists.txt index c26c8bdaf..1a53217d9 100644 --- a/tests/soak/CMakeLists.txt +++ b/tests/soak/CMakeLists.txt @@ -14,7 +14,7 @@ target_link_libraries(morph_soak PRIVATE morph::morph morph_test_main) target_include_directories(morph_soak PRIVATE ${CMAKE_SOURCE_DIR}/tests) apply_warnings(morph_soak) -# morph#542 left the soak/bench decision explicit rather than assumed. Both are +# The soak/bench decision is explicit rather than assumed. Both are # instrumented: they are opt-in (-DMORPH_BUILD_LOAD_TESTS=ON), so no default # sanitizer leg pays for them, and switchBackend/reconnect churn over many # cycles is exactly the shape of test whose finding is a leak or a race rather diff --git a/tests/test_action_log.cpp b/tests/test_action_log.cpp index 5e4ea594b..7749b649f 100644 --- a/tests/test_action_log.cpp +++ b/tests/test_action_log.cpp @@ -68,8 +68,8 @@ struct ALGetBalance {}; struct ALSetNickname { std::string name; }; -// Throws when overdrawn -- the case issue #23 is about: a rejected action must -// still leave a journal entry, not silence. +// Throws when overdrawn -- a rejected action must still leave a journal entry, +// not silence. struct ALWithdraw { int amount = 0; }; diff --git a/tests/test_action_log_phase2.cpp b/tests/test_action_log_phase2.cpp index 161167911..2ffc5ce80 100644 --- a/tests/test_action_log_phase2.cpp +++ b/tests/test_action_log_phase2.cpp @@ -616,7 +616,7 @@ TEST_CASE("FileActionLog::rotate: promotes unflushed idempotencyKeys into durabl REQUIRE(sealedEntries[0].idempotencyKey == "row-1"); } -// ── FileIoOps fault injection (LASTRADA-Software/morph#97) ───────────────── +// ── FileIoOps fault injection ───────────────────────────────────────────── // // Every branch below only runs when a real OS-level file-I/O call fails // partway through an otherwise-successful operation -- previously @@ -646,10 +646,10 @@ TEST_CASE("FileActionLog::append: a short fwrite() throws and does not record th REQUIRE(log2.entries().size() == 1); } -TEST_CASE("FileActionLog::append: a short write does not merge with the next successful append (morph#530)", +TEST_CASE("FileActionLog::append: a short write does not merge with the next successful append", "[action_log][phase2][file][fault-injection]") { - // Regression for morph#530. The single-write case above (fwrite always - // short) never exercises the actual defect: append() used to throw on a + // The single-write case above (fwrite always + // short) never exercises the actual defect: append() must not throw on a // short write without rolling the file back, and the handle is // append-mode, so a *subsequent* successful append concatenated directly // onto the truncated JSON with no separating newline -- merging two @@ -782,7 +782,7 @@ TEST_CASE("FileActionLog::rotate: a failing pre-rotation fsync() throws before a REQUIRE(log.entries().size() == 1); } -// ── Directory fsync (morph#532) ────────────────────────────────────────── +// ── Directory fsync ────────────────────────────────────────── // // `fsync` on a file makes its *data* durable but not a new directory entry // or a rename -- the constructor's first `fopen("a")` can create the file, @@ -791,7 +791,7 @@ TEST_CASE("FileActionLog::rotate: a failing pre-rotation fsync() throws before a // confirm it is actually called at each site, with the right directory, and // that a failure there is surfaced rather than swallowed. -TEST_CASE("FileActionLog: construction syncs the containing directory after creating the file (morph#532)", +TEST_CASE("FileActionLog: construction syncs the containing directory after creating the file", "[action_log][phase2][file][fault-injection]") { TempFile const tmp{"file_fault_construct_syncpath"}; std::vector syncedPaths; @@ -807,7 +807,7 @@ TEST_CASE("FileActionLog: construction syncs the containing directory after crea CHECK(syncedPaths[0] == tmp.path.parent_path()); } -TEST_CASE("FileActionLog: an unsupported directory fsync warns instead of throwing (morph#532)", +TEST_CASE("FileActionLog: an unsupported directory fsync warns instead of throwing", "[action_log][phase2][file][fault-injection]") { // A directory fsync needs a *read* handle on the directory, strictly // stronger than writing a file inside it: on a mode-0300 spool directory -- @@ -858,7 +858,7 @@ TEST_CASE("FileActionLog: a failing directory fsync during construction throws a REQUIRE(reopened.entries().size() == 1); } -TEST_CASE("FileActionLog::rotate: syncs both the seal rename's and the reopen's directory (morph#532)", +TEST_CASE("FileActionLog::rotate: syncs both the seal rename's and the reopen's directory", "[action_log][phase2][file][fault-injection]") { TempFile const active{"file_fault_rotate_syncpath_active"}; TempFile const sealed{"file_fault_rotate_syncpath_sealed"}; @@ -907,7 +907,7 @@ TEST_CASE( "FileActionLog::rotate: a failing reopen after a successful rename leaves the log closed, " "requireOpen()'s throwing arm reachable, and the destructor's null check load-bearing", "[action_log][phase2][file][fault-injection]") { - // The one scenario morph#97 called out as needing the *most* real-world + // The scenario needing the *most* real-world // contortion to reach without this seam: fopen() failing on the reopen // right after the rename to sealedPath already succeeded. With FileIoOps, // this is just "let the constructor's own fopen() through for real, then @@ -1019,7 +1019,7 @@ TEST_CASE("FileActionLog: a torn trailing record whose resize_file() fails is lo REQUIRE(std::filesystem::file_size(tmp.path) == sizeBefore); } -// ── An unreadable journal must never be mistaken for a torn one (morph#493) ── +// ── An unreadable journal must never be mistaken for a torn one ── // // repairTornTail() scans with an ifstream whose open it did not check, so a // scan that never happened left `intactEnd` at 0 and truncated the whole file diff --git a/tests/test_async_registration.cpp b/tests/test_async_registration.cpp index 8bd622367..8a5c7a09d 100644 --- a/tests/test_async_registration.cpp +++ b/tests/test_async_registration.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// Coverage for issue #26: Bridge::registerHandler() and the keyed +// Bridge::registerHandler() and the keyed // attach/promote entry points reach the backend through the structural // registration surface (IBackend::bindModel / promoteModel), and a backend // whose reply arrives later must not block the caller. @@ -13,8 +13,7 @@ // registerModel does -- the pattern this issue is about, since Qt refuses to // spin a nested loop on a WASM main thread at all). // -// Until morph#571 that shape was expressed by overriding four optional -// `*Async` twins that returned `bool`; the doubles here now express it by +// The doubles here express that shape by // overriding bindModel/promoteModel and answering // BindWait::kCallerMustNotBlock, which is what makes registerHandlerImpl // return without waiting -- the same observable behaviour the `true` return @@ -171,7 +170,7 @@ struct morph::model::ActionKeyTraits { BRIDGE_MODEL_KEY(ARKeyedModel, ARTouch, &ARTouch::id); BRIDGE_KEY_FROM_RESULT(ARKeyedCreate, &ARKeyedCreated::id); -// ── Issue #67: assignHandlerPrimary goes through IBackend::promoteModel ──── +// ── assignHandlerPrimary goes through IBackend::promoteModel ────────────── // // A model whose result-keyed action (BRIDGE_KEY_FROM_RESULT) drives // Bridge::assignHandlerPrimary. Needs **external** linkage (not an anonymous @@ -388,14 +387,14 @@ class ThrowingDispatchBackend : public AsyncRegisterBackend { // Tries to settle the same bind twice, inline, from inside bindModel itself. // Bridge::attachHandlerAsync must still report exactly once. // -// Note what moved with morph#571: the twins handed the Bridge two raw -// std::functions, so a second call reached detail::parkIfInFrame's own guard. -// A Completion cannot be settled twice -- CompletionState drops the second -// settle before any Bridge code sees it -- so this double now pins the -// *observable* contract ("exactly one onDone") while the guard inside -// parkIfInFrame is no longer reachable from a backend. See the PR for morph#571. -// The guard itself is pinned by a direct call to parkIfInFrame instead -// (morph#648), next to the test case this double drives. +// Note what settling a `Completion` changes here: raw callbacks would hand +// the Bridge two std::functions, so a second call would reach +// detail::parkIfInFrame's own guard. A Completion cannot be settled twice -- +// CompletionState drops the second settle before any Bridge code sees it -- +// so this double pins the *observable* contract ("exactly one onDone") while +// the guard inside parkIfInFrame is not reachable from a backend at all. +// The guard itself is pinned by a direct call to parkIfInFrame instead, +// next to the test case this double drives. class DoubleFiringBackend : public AsyncRegisterBackend { public: ModelCompletion bindModel(morph::backend::detail::BindRequest request, morph::exec::IExecutor& cbExec) override { @@ -1060,7 +1059,7 @@ TEST_CASE("Bridge::registerHandler: binds inline for a backend with no non-block CHECK(binding->currentId.load() != 0U); } -// ── Issue #60: registration-settled seam (whenBound/isBound) ──────────────── +// ── The registration-settled seam (whenBound/isBound) ────────────────────── // // executeVia() fails fast with "handler not bound" for a binding whose async // registration hasn't round-tripped yet. Bridge::whenBound() gives a caller a @@ -1128,8 +1127,8 @@ TEST_CASE("BridgeHandler::whenBound: fires once the deferred async registration CHECK_FALSE(errored); CHECK(handler.isBound()); - // Once bound, dispatching immediately (the exact scenario issue #60 - // describes -- a dispatch issued right after connect) must succeed rather + // Once bound, dispatching immediately -- a dispatch issued right after + // connect -- must succeed rather // than fail fast with "handler not bound". std::atomic result{-1}; handler.execute(ARCount{.x = 3}).then([&](int v) { result.store(v); }).onError([](const std::exception_ptr&) {}); @@ -1175,7 +1174,7 @@ TEST_CASE("Bridge::whenBound: multiple waiters on the same in-flight registratio CHECK(resolvedCount == 3); } -// ── Issue #67: assignHandlerPrimary goes through IBackend::promoteModel ──── +// ── assignHandlerPrimary goes through IBackend::promoteModel ────────────── // // A result-keyed action's execute() calls ensureBound() then, once the reply // names the key, assignHandlerPrimary(). When the backend settles its @@ -2163,8 +2162,8 @@ TEST_CASE("attachHandlerAsync reports exactly once even when the backend fires i // publish the binding) exactly once for a single dispatch. // // What makes that hold is CompletionState, not detail::parkIfInFrame's - // `handoff.fired` guard -- this comment used to name the guard, and was - // wrong from morph#571 onwards (morph#648). The second promise.resolve() + // `handoff.fired` guard, which is the obvious guess and the wrong one. + // The second promise.resolve() // below is dropped by the already-settled state before any Bridge code // sees it, so parkIfInFrame is entered once and its double-claim arm is // never taken. That arm is pinned separately, by the direct-call case @@ -2189,8 +2188,8 @@ TEST_CASE("attachHandlerAsync reports exactly once even when the backend fires i TEST_CASE("parkIfInFrame swallows a second claim on the same handoff and keeps the first outcome", "[bridge][registration][shared-instances][issue26]") { - // The arm the case above used to claim to exercise, driven where it can - // actually be reached: directly (morph#648). + // The arm the case above looks like it exercises, driven where it can + // actually be reached: directly. // // No backend reaches it any more -- every dispatch site parks one // Completion's outcome, and a CompletionState settles once -- so without @@ -2277,7 +2276,7 @@ TEST_CASE("ensureBoundAsync's out-of-frame success callback is a genuine no-op o } // --------------------------------------------------------------------------- -// Coverage for LASTRADA-Software/morph#108: attachHandlerAsync's two +// attachHandlerAsync's two // success-path `catch (...)` blocks (the out-of-frame callback below, and its // in-frame claimHandoff counterpart) only ever fire on std::bad_alloc from a // real allocation failure inside the strongBinding->contextKey/primary copy- @@ -2382,7 +2381,7 @@ TEST_CASE( // portably would need either a structural change that gives the target copy // a distinguishable allocation shape, or a seam finer-grained than a global // allocator override can offer -- disproportionate machinery for one -// branch. Tracked by the same morph#108, not a second, separate ask. +// branch, so that one is left uncovered. TEST_CASE( "Bridge::attachHandler (sync): attaching a fresh, never-attached binding to an empty key still performs a " @@ -2411,13 +2410,14 @@ TEST_CASE( CHECK(binding->primary.empty()); } -// ── morph#588: the bridge's own executor for late registration replies ────── +// ── The bridge's own executor for late registration replies ──────────────── // // Every Bridge dispatch site names `inlineExecutor()` on the `bindModel`/ -// `promoteModel` call, so a reply that arrives after the dispatching frame has -// gone used to be published on whichever thread the backend settled it on -- -// the morph#486 thread. `Bridge`'s optional `bridgeExec` constructor argument -// is where that decision lives now. The three cases below pin the three halves +// `promoteModel` call, so without an executor of its own a reply that arrives +// after the dispatching frame has gone is published on whichever thread the +// backend settled it on -- which can be the thread running `~Bridge`. +// `Bridge`'s optional `bridgeExec` constructor argument +// is where that decision lives. The three cases below pin the three halves // of the contract: a late reply goes through the executor, an in-frame reply // does not, and no executor means exactly the old behaviour. @@ -2475,9 +2475,9 @@ TEST_CASE("Bridge(bridgeExec): a registration reply that misses its dispatch fra REQUIRE(binding->currentId.load() == 0U); REQUIRE(bridgeExec.queued() == 0); - // The reply lands. Before morph#588 this published the id right here, on - // completeNext()'s own thread; now it is a task on the bridge's executor - // and nothing is published until that executor runs it. Restoring inline + // The reply lands. With no bridge executor this publishes the id right + // here, on completeNext()'s own thread; with one it is a task on that + // executor and nothing is published until the executor runs it. Restoring inline // delivery makes the next two lines fail rather than merely not-prove. rawBackend->completeNext(); CHECK(binding->currentId.load() == 0U); @@ -2514,10 +2514,10 @@ TEST_CASE("Bridge(bridgeExec): a bind that settles inside the dispatch frame is CHECK(neverDrained.queued() == 0); } -TEST_CASE("Bridge(): with no executor, a late registration reply is delivered inline, as before morph#588", +TEST_CASE("Bridge(): with no executor, a late registration reply is delivered inline", "[bridge][registration][issue588]") { // The default. `Bridge`'s new argument must compose (framework invariant - // 2), so omitting it has to leave the pre-morph#588 behaviour byte for + // 2), so omitting it has to leave inline delivery byte for // byte: the reply publishes on completeNext()'s own thread, with no // executor anywhere in the path. auto backend = std::make_unique(); diff --git a/tests/test_backend_extra.cpp b/tests/test_backend_extra.cpp index 8c14819f4..9f6aced1f 100644 --- a/tests/test_backend_extra.cpp +++ b/tests/test_backend_extra.cpp @@ -303,11 +303,11 @@ TEST_CASE("morph::backend::LocalBackend: one execute produces exactly one beginS REQUIRE(endCalls.load() == 1); } -// ── morph::backend::LocalBackend: amortised pending-list compaction (morph#528) ──────────────── +// ── morph::backend::LocalBackend: amortised pending-list compaction ───────── namespace { -/// Builds an `ActionCall` whose local op is @p op — the two morph#528 cases +/// Builds an `ActionCall` whose local op is @p op — the two compaction cases /// below differ only in that op. morph::backend::detail::ActionCall pendingCall(std::function op) { morph::backend::detail::ActionCall call; @@ -344,7 +344,7 @@ morph::backend::detail::ActionCall pendingCall(std::function op) { // What this does *not* assert is admission latency — the reason the sweep was // made amortised in the first place. That is measured by a benchmark, not by a // test; a wall-clock assertion on a shared CI runner would be a flake, not -// evidence. The morph#528 numbers are recorded in docs/spec/core/backend.md. +// evidence. The measured numbers are in docs/spec/core/backend.md. TEST_CASE("morph::backend::LocalBackend: amortised pending compaction bounds the list and keeps cancelPending whole", "[backend][local][pending]") { constexpr int kRounds = 48; diff --git a/tests/test_backend_registration_surface.cpp b/tests/test_backend_registration_surface.cpp index 6d760152a..5b58e5b6b 100644 --- a/tests/test_backend_registration_surface.cpp +++ b/tests/test_backend_registration_surface.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// Coverage for issue #567: IBackend's structural registration surface +// IBackend's structural registration surface // (`bindModel`/`promoteModel`, `BindRequest`/`PromoteRequest`) and // `SynchronousBackendAdapter`. // @@ -136,7 +136,7 @@ struct RecordingBackend : IBackend { }; /// @brief A backend whose registration genuinely never blocks — the shape -/// morph#568 moves `QtWebSocketBackend` onto. +/// `QtWebSocketBackend` implements natively. /// /// `bindModel` stores the promise and returns; the reply is delivered later, /// from whatever thread the transport happens to use. @@ -521,7 +521,7 @@ TEST_CASE( "setConnectHandler", "setDisconnectHandler", "setSession:pal"}); } -// ── `bindWaitPolicy`: the one bit `Completion` cannot carry (morph#593) ────── +// ── `bindWaitPolicy`: the one bit `Completion` cannot carry ──────────────── // // Two backends both return an unsettled `Completion` from `bindModel`, and // `Bridge::registerHandler` — a synchronous entry point whose caller uses the @@ -594,8 +594,8 @@ TEST_CASE("morph::bridge::Bridge: registerHandler waits out a kCallerMayBlock ba auto binding = bridge.registerHandler(); // No polling, no drain: the constructor did not return until the reply - // landed. This is the contract every non-Qt embedder had before morph#568 - // and that morph#586 took away from `SocketBackend`. + // landed. This is the contract every non-Qt embedder relies on, and what + // `kCallerMayBlock` preserves for a natively non-blocking `SocketBackend`. REQUIRE(morph::bridge::Bridge::isBound(binding)); REQUIRE(binding->currentId.load() == 99U); // ...and it was a *wait*, not a synchronous backend: the value was produced @@ -615,7 +615,7 @@ TEST_CASE("morph::bridge::Bridge: registerHandler does not wait for a kCallerMus // Returned while the reply is still 200 ms away. For `QtWebSocketBackend` // under `asyncRegistrationEnabled` this is not a preference: the reply is // delivered by the Qt event loop of this very thread, so a `registerHandler` - // that waited here would never return (morph#568's WASM page abort). + // that waited here would never return -- on WASM, a page abort. REQUIRE_FALSE(morph::bridge::Bridge::isBound(binding)); REQUIRE(morph::testing::waitUntil([&] { return morph::bridge::Bridge::isBound(binding); })); @@ -623,7 +623,7 @@ TEST_CASE("morph::bridge::Bridge: registerHandler does not wait for a kCallerMus REQUIRE(backend->settledOffCallerThread.load()); } -// ── cancelPending and the completions the adapter itself produced (#619) ───── +// ── cancelPending and the completions the adapter itself produced ────────── // // `SynchronousBackendAdapter::cancelPending` used to be a one-line forward to // the wrapped backend. The two verbs the adapter *reshapes* settle from a task @@ -753,9 +753,9 @@ TEST_CASE("morph::backend::SynchronousBackendAdapter: cancelPending rejects the REQUIRE(errRan.load() == 1); } -// ── cancelPending and the control call the strand has not started yet (#636) ─ +// ── cancelPending and the control call the strand has not started yet ────── // -// #619's case above is about the *completion*: it must be rejected. This one is +// The case above is about the *completion*: it must be rejected. This one is // about the *work behind it*: a task still queued on `_control` when // `cancelPending` runs must never make its blocking control call at all. // Settling the promise cannot achieve that -- the task's own `resolve` being a diff --git a/tests/test_bridge_fixes.cpp b/tests/test_bridge_fixes.cpp index 91e59b4e9..91820d18b 100644 --- a/tests/test_bridge_fixes.cpp +++ b/tests/test_bridge_fixes.cpp @@ -85,7 +85,7 @@ class ReactModel { // A model whose onBackendChanged RE-ENTERS the bridge by calling // registerHandler + deregisterHandler on the SAME bridge — the conflict- -// resolution reentrancy the audit flagged (#14). Under the old design, +// resolution reentrancy the audit flagged. Under a single-slot design, // notifyBackendChanged ran inline while Bridge::_mtx was held, so any of these // re-entrant calls (which also take _mtx) self-deadlocked. With strand dispatch // the callback runs on a pool thread with _mtx free, so the re-entrant calls diff --git a/tests/test_bridge_lifetime.cpp b/tests/test_bridge_lifetime.cpp index da5264ad3..0b25b527e 100644 --- a/tests/test_bridge_lifetime.cpp +++ b/tests/test_bridge_lifetime.cpp @@ -25,8 +25,8 @@ // reported Catch2 test failure; post-fix, the liveness check gates // the call out before the page is ever touched. // -// morph#489 changed *how* both call sites are made safe, without -// changing what either test observes (both still pass unmodified): +// *How* both call sites are made safe is separate from what either +// test observes: // `_pendingCalls` and `_subscriptions` are now heap-allocated and // captured by value into the `.then`/`.onError` continuations, so // `hasSubscribers()` in particular no longer dereferences `this` at @@ -211,7 +211,7 @@ class DeferredResultBackend : public morph::backend::detail::IBackend { std::shared_ptr>> state; }; -// ── morph#486 fixtures ─────────────────────────────────────────────────────── +// ── Teardown-race fixtures ────────────────────────────────────────────────── // Shared bookkeeping for the teardown-race case below. It lives outside the // backend on purpose: pre-fix, `~Bridge` destroys the backend while a parked @@ -602,15 +602,15 @@ TEST_CASE("Bridge: hasSubscribers is not read once the bridge is destroyed (guar } #endif // !defined(_WIN32) -// ── morph#486: ~BridgeHandler racing ~Bridge across threads ────────────────── +// ── ~BridgeHandler racing ~Bridge across threads ──────────────────────────── // // `docs/spec/core/bridge.md` promises that bridge-vs-handler teardown order // does not matter, and `~BridgeHandler` implemented that promise with a bare // `CallbackToken::active()` check. Across threads that check is advisory by // construction (`docs/spec/core/callback_scope.md`, "Boundary of the // guarantee"): it answers for an instant that has already passed by the time -// `Bridge::deregisterHandler` reads `_handlers`. morph#486 is that window, -// observed as a use-after-free — a metadata-fetch pass kept its +// `Bridge::deregisterHandler` reads `_handlers`. That window is +// observable as a use-after-free — a metadata-fetch pass keeps its // `shared_ptr` alive inside the completions it dispatched, a // worker-pool thread dropped the last reference while the owning thread was // inside `~App`, and `deregisterHandler` then walked a `_handlers` vector whose @@ -630,7 +630,7 @@ TEST_CASE("Bridge: hasSubscribers is not read once the bridge is destroyed (guar // earns its keep on the plain leg, not the sanitizer ones.** ASan's // instrumentation slows `~Bridge` enough that the parked handler wins every // round — 0/25 pre-fix failures and no `heap-use-after-free`, which is also why -// ASan never reproduced morph#486 itself (0/200 runs of the bookmarks case it +// ASan never reproduces the original race (0/200 runs of the bookmarks case it // was reported from, against 26/200 unsanitized). TEST_CASE("Bridge teardown does not overlap a handler destructor on another thread", "[bridge][lifetime][teardown][issue486]") { diff --git a/tests/test_bridge_pending_calls.cpp b/tests/test_bridge_pending_calls.cpp index 951fa68e5..4e7df67cf 100644 --- a/tests/test_bridge_pending_calls.cpp +++ b/tests/test_bridge_pending_calls.cpp @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 -// Regression coverage for issue #45: Bridge has no pendingCalls() for -// client-side quiescence observability. These tests exercise the in-flight +// `Bridge::pendingCalls()` is the client-side quiescence signal. +// These tests exercise the in-flight // counter tracked by Bridge::executeVia() (dispatched via // BridgeHandler::execute()), incremented on dispatch and decremented when the // returned Completion resolves — on success, on error, and when the handler @@ -27,7 +27,7 @@ std::atomic gPendingCallsSlowRelease{false}; // A function-local static rather than a namespace-scope one, unlike its two // neighbours above: `cppcoreguidelines-avoid-non-const-global-variables` is on // for tests/ and fires on the latter. The two above predate the changed-lines -// clang-tidy gate and are not reported (morph#677). +// clang-tidy gate and are not reported. std::atomic& pcSlowFinished() { static std::atomic value{0}; return value; @@ -171,7 +171,7 @@ TEST_CASE("Bridge: pendingCalls() does not increment for a synchronously-failed REQUIRE(bridge.pendingCalls() == 0); } -// ── morph#502: a throwing backend->execute() must not leak the slot ── +// ── A throwing backend->execute() must not leak the slot ── // // executeVia() incremented `_pendingCalls` and armed the client deadline before // calling `backend->execute(...)`, which was not wrapped in a try. That call is @@ -186,7 +186,7 @@ namespace { /// Overrides `executeInto`, not `execute`: `Bridge::executeVia` dispatches /// through the former, and `LocalBackend::execute` is `final` precisely so a /// double written the other way round is a compile error rather than a test -/// that quietly stops intercepting anything (morph#572, Part B). +/// that quietly stops intercepting anything. struct ThrowingExecuteBackend : morph::backend::LocalBackend { using morph::backend::LocalBackend::LocalBackend; @@ -213,7 +213,7 @@ TEST_CASE("Bridge: a throwing backend execute() leaves pendingCalls() at zero", CHECK(bridge.pendingCalls() == 0); } -// ── morph#572 Part B: cancelPending racing the real reply settles once ── +// ── cancelPending racing the real reply settles once ── // // Before Part B, "exactly one decrement per dispatch" was carried by the fact // that `.then` and `.onError` are mutually exclusive on one `CompletionState`: diff --git a/tests/test_callback_scope.cpp b/tests/test_callback_scope.cpp index 6f50ddd76..db42537f1 100644 --- a/tests/test_callback_scope.cpp +++ b/tests/test_callback_scope.cpp @@ -1,10 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 // -// Tests for `morph::async::CallbackScope` / `CallbackToken` (issue #138): the +// Tests for `morph::async::CallbackScope` / `CallbackToken`: the // lifetime-and-stop gate a receiver holds as a *data member* (not a base class) // and hands to the callbacks it attaches. // -// Verification strategy, carried over from #150 and required by the issue: the +// Verification strategy: the // call counters live in `shared_ptr`s that **outlive the receiver**, so "the // callback body did not run" is directly observable rather than resting on a // sanitizer noticing UB after the fact. A test that destroys the receiver and @@ -575,7 +575,7 @@ TEST_CASE("CallbackScope: destroying the scope under a concurrent dispatch loop } } -// ── morph#499: what CallbackScope's concurrency contract actually covers ── +// ── What CallbackScope's concurrency contract actually covers ── // // The class used to document *every* member as concurrently safe. It is not: // `reset()` replaces the `_state` handle, and reading a shared_ptr while another diff --git a/tests/test_client_execute_deadline.cpp b/tests/test_client_execute_deadline.cpp index eb882b55d..926357abf 100644 --- a/tests/test_client_execute_deadline.cpp +++ b/tests/test_client_execute_deadline.cpp @@ -8,7 +8,7 @@ // // A frame refused by QtWebSocketServerConfig::messagesPerSecond used to belong // on that list too; it no longer does, since the transport now answers it with -// an `err "rate limited"` (morph#225). The deadline still covers the cases no +// an `err "rate limited"`. The deadline still covers the cases no // reply can. #include @@ -212,7 +212,7 @@ TEST_CASE("Bridge::setExecuteDeadline fires ClientTimeoutError when no reply arr // TimeoutScheduler's own background thread, which posts to `exec` -- // give it real wall-clock slack, matching this codebase's other // cross-thread test patterns. REQUIRE on the pump's own return, not on - // `failed` afterwards (morph#396): if the budget expires first, this + // `failed` afterwards: if the budget expires first, this // reports "the wait itself timed out" rather than failing a REQUIRE on // `failed` that would abort the case before `threwClientTimeout` -- // naming the wrong half of the answer -- is ever checked. @@ -310,7 +310,7 @@ TEST_CASE("A real reply that arrives after the deadline already fired is silentl }); // REQUIRE on the pump's own return, not on `settleCount` afterwards - // (morph#396's own shape): if the 50ms deadline never fires within budget, + // (the same shape): if the 50ms deadline never fires within budget, // this reports "the wait itself timed out" rather than a `settleCount == // 1` REQUIRE that would abort before `threwClientTimeout` is ever checked. REQUIRE(pumpUntil(exec, [&] { return settleCount != 0; })); diff --git a/tests/test_completion_branches.cpp b/tests/test_completion_branches.cpp index 65f06ba26..b69acb559 100644 --- a/tests/test_completion_branches.cpp +++ b/tests/test_completion_branches.cpp @@ -103,7 +103,7 @@ TEST_CASE("CompletionState: orphan destructor logs std::exception", "[completion } TEST_CASE("CompletionState: an orphan destructor survives a throwing log sink", "[completion][logger]") { - // The motivating case for morph#158. ~CompletionState is implicitly + // The motivating case for the noexcept guarantee. ~CompletionState is implicitly // noexcept and logs the abandoned exception, so before the logging layer // became noexcept a sink that threw here meant std::terminate -- which the // destructor worked around with a local try/catch(...) and a NOLINT. That diff --git a/tests/test_completion_multi_handler.cpp b/tests/test_completion_multi_handler.cpp index 8aae1e3c3..7c4365161 100644 --- a/tests/test_completion_multi_handler.cpp +++ b/tests/test_completion_multi_handler.cpp @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 -// Regression coverage for issue #59: Completion::onError() (and, symmetrically, -// then()) used to keep only the last-attached handler in a single field, silently +// Completion::onError() (and, symmetrically, +// then()) must not keep only the last-attached handler in a single field, silently // discarding any earlier one. These tests pin down the fixed, composing behavior: // every handler attached while the state is not yet ready runs when the outcome // arrives, in attachment order. @@ -22,7 +22,7 @@ using LogGuard = morph::log::ScopedLoggerOverride; namespace { /// A value whose *copy* constructor throws on demand. Under the value contract -/// (morph#553) nothing on the value path copies `T`, so an armed `ThrowOnCopy` +/// nothing on the value path copies `T`, so an armed `ThrowOnCopy` /// settling through `const T&` handlers is a booby trap that must never go off /// -- which is what turns "zero copies" from a comment into a test. The flag /// travels with the value rather than living in a global so two tests cannot @@ -123,7 +123,7 @@ TEST_CASE("Completion: onError handlers attached after error is ready all fire ( } TEST_CASE("Completion: a second onError attached before ready does not discard the first", "[completion][issue-59]") { - // This is the exact reproducer from issue #59. + // The minimal reproducer for a single-slot handler field. SyncExecutor exec; auto state = std::make_shared>(); morph::async::Completion comp{state, &exec}; @@ -232,8 +232,7 @@ TEST_CASE("Completion: mismatched attach (onError on a value-ready state) is sti REQUIRE_FALSE(errFired2); } -// Regression coverage for morph#520 (part of the sweep tracked in #518, finding F2). -// setValue() used to move out of its own `value` optional to build the settle-time +// `setValue()` must not move out of its own `value` optional to build the settle-time // fan-out closure for handlers attached *before* settling, leaving `value` engaged // but holding a moved-from T. A then() attached *after* settling (attachThen's // `ready && value` branch) then copied that husk instead of the real value. The @@ -280,9 +279,9 @@ TEST_CASE("Completion: settling never copies T -- an armed throwing copy constru // `onOk` is erased as `std::function` and both dispatch // paths read the stored value in place, so settling a state with `const T&` // handlers -- attached before *or* after -- copies `T` exactly zero times. - // An armed `ThrowOnCopy` therefore settles without incident. Before - // morph#553 this threw: `setValue` copied into `savedVal` before draining - // `onOk`, and `attachThen`'s fire-now path copied twice more. + // An armed `ThrowOnCopy` therefore settles without incident. Under an + // erasure that copies, this throws: `setValue` copies into `savedVal` before + // draining `onOk`, and the fire-now path copies twice more. SyncExecutor exec; auto state = std::make_shared>(); morph::async::Completion comp{state, &exec}; diff --git a/tests/test_completion_promise.cpp b/tests/test_completion_promise.cpp index 3d0df76a6..8bc3f0366 100644 --- a/tests/test_completion_promise.cpp +++ b/tests/test_completion_promise.cpp @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Covers the public "settleable promise" seam for `morph::async::Completion` -// (issue #55, use case 1): a test-facing way to construct a `Completion` it +// a test-facing way to construct a `Completion` it // can resolve on demand, without reaching into `morph::async::detail::CompletionState`. #include @@ -92,7 +92,7 @@ TEST_CASE("morph::async::Completion::makeSettleable: Promise does not expose SUCCEED(); } -// ── Issue #347: a null exception_ptr must not settle the error arm ────────── +// ── A null exception_ptr must not settle the error arm ───────────────────── // // `setException(nullptr)` used to set `ready` while leaving `error` falsy — a // state neither `attachOnError` (which tests `ready && error`) nor `attachThen` diff --git a/tests/test_completion_value_contract.cpp b/tests/test_completion_value_contract.cpp index 1824cf1e0..e42dfa5ed 100644 --- a/tests/test_completion_value_contract.cpp +++ b/tests/test_completion_value_contract.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -// The value-handling contract of morph::async::Completion (morph#553), +// The value-handling contract of morph::async::Completion, // pinned by counting rather than by reading the header: // // - `T` need only be move-constructible. `Completion>` @@ -205,8 +205,8 @@ TEST_CASE("Completion value contract: a mixed handler set charges one copy per b } TEST_CASE("Completion value contract: T need only be move-constructible", "[completion][issue-553]") { - // `Completion>` did not compile before morph#553 -- - // four sites copied `T` on the value path. Instantiating is not evidence on + // `Completion>` does not compile if any site copies + // `T` on the value path. Instantiating is not evidence on // its own, so this fans out to handlers attached on both sides of the // settle and checks each one actually ran against the real value. SyncExecutor exec; diff --git a/tests/test_coverage_gaps.cpp b/tests/test_coverage_gaps.cpp index 29f9ad025..ebd3e669e 100644 --- a/tests/test_coverage_gaps.cpp +++ b/tests/test_coverage_gaps.cpp @@ -407,8 +407,8 @@ TEST_CASE("morph::offline::NetworkMonitor: stop() from inside probe detaches and // 489-490, 518-519 and 532-533, and named a type to go with it. By the time // anyone checked, that name matched nothing anywhere in the tree and all three // ranges had drifted onto unrelated code — mid-sentence in a doc comment, a -// ModelId load, a parkIfInFrame guard (morph#419, after morph#349 and morph#355 -// in the same shape). The dead name is not repeated here on purpose: a grep for +// ModelId load, a parkIfInFrame guard -- three separate times, in the same +// shape. The dead name is not repeated here on purpose: a grep for // it must come back empty, or the comment reads as a live reference to whoever // runs that grep next. Nothing verifies either half of such a citation, so when // a name and a number disagree, believe the name. diff --git a/tests/test_dispatch_di.cpp b/tests/test_dispatch_di.cpp index f0137e4e8..3779911d9 100644 --- a/tests/test_dispatch_di.cpp +++ b/tests/test_dispatch_di.cpp @@ -95,7 +95,7 @@ TEST_CASE("Two isolated dispatchers do not share state", "[di]") { REQUIRE_THROWS_AS(dispatcher2.dispatch("DiModel", "DiAction", *holder, R"({"x":4})"), std::runtime_error); } -// Note: coverage for the registry-constructed-model DI seam (issue #56) lives +// Note: coverage for the registry-constructed-model DI seam lives // in tests/test_registry_extra.cpp ("ModelRegistryFactory: registerModel // accepts a custom factory closure" et al.) to avoid duplicating the same // scenario across two files. diff --git a/tests/test_execute_order_gate.cpp b/tests/test_execute_order_gate.cpp index 1398429d5..f7b575b4e 100644 --- a/tests/test_execute_order_gate.cpp +++ b/tests/test_execute_order_gate.cpp @@ -8,7 +8,7 @@ // -- no socket, no ThreadPoolExecutor, no IAuthorizer, no wire envelopes, no // RemoteServer at all. Before this extraction, reaching the "gate already // erased" defensive branches and the out-of-order-release mechanism -// (`releasedOutOfOrder`, issue #449) cost `tests/test_remote_execute_ordering.cpp` +// (`releasedOutOfOrder`) cost `tests/test_remote_execute_ordering.cpp` // a full ThreadPoolExecutor, bespoke IAuthorizer subclasses that force // deterministic interleaving, a real register round-trip, and hand-encoded // envelopes. Here they are a handful of synchronous calls. @@ -17,7 +17,7 @@ // it keeps the cases that need RemoteServer's real dispatch path (send-order // preservation through handle()/dispatchExecute, and the shutdown-gate/throw // interactions that only manifest through that real call sequence) -- but the -// gate's own internal state machine, including the #449 mechanism, now has +// gate's own internal state machine, including that mechanism, has // its direct coverage here instead of only being reachable by forcing thread // interleavings through the whole server. @@ -154,13 +154,13 @@ TEST_CASE("ExecuteOrderGate: awaitTurn and release both cope once a gate has ful CHECK(gate.gateCount() == 0U); } -// ── out-of-order release (issue #449) ─────────────────────────────────────── +// ── out-of-order release ──────────────────────────────────────────────────── TEST_CASE( "ExecuteOrderGate: an out-of-order release is recorded rather than applied, " "and resolves once the gap it was waiting on closes", "[remote][execute-order-gate][449]") { - // Threadless reproduction of the #449 mechanism. Three tickets for one + // Threadless reproduction of that mechanism. Three tickets for one // model, released out of ticket order: // 0 (never released yet -- the "earlier ticket still outstanding") // 1 (released first -- out of order relative to 0) @@ -224,7 +224,7 @@ TEST_CASE( "[remote][execute-order-gate][449]") { // Same mechanism as above, but observed through a genuinely blocked // awaitTurn call rather than only through gateCount() -- the real-thread - // half of the #449 regression, kept minimal (one waiter, one release) + // half of the same guarantee, kept minimal (one waiter, one release) // since the pure state-machine behavior is already pinned above. ExecuteOrderGate gate; ModelId const mid{1}; @@ -414,8 +414,7 @@ TEST_CASE( // and confirm the second call is inert rather than inserting an // already-passed ticket number into releasedOutOfOrder a second time -- // which would sit there as that set's permanent minimum and silently - // block every future out-of-order release for this gate (issue #449's - // own mechanism). + // block every future out-of-order release for this gate. ExecuteOrderGate gate; ModelId const mid{1}; auto t0 = gate.takeTicket(mid); @@ -430,7 +429,7 @@ TEST_CASE( CHECK(gate.gateCount() == 0U); } -// ── Cross-model re-entrancy must not deadlock two threads (morph#519) ─────── +// ── Cross-model re-entrancy must not deadlock two threads ────────────────── // // `takeAndPost` holds an enqueue mutex across `postFn`, and `postFn` is opaque: // on a `ThreadPoolExecutor` it only enqueues, but on a synchronous executor it diff --git a/tests/test_executor.cpp b/tests/test_executor.cpp index 938ff81ec..638b2bf1b 100644 --- a/tests/test_executor.cpp +++ b/tests/test_executor.cpp @@ -130,7 +130,7 @@ TEST_CASE("morph::exec::MainThreadExecutor drain runs a bounded chain of tasks t REQUIRE(count.load() == chainLength); } -// ── morph#501: a non-std::exception must not escape the main-thread pump ── +// ── A non-std::exception must not escape the main-thread pump ── // // runTask() caught only `const std::exception&`, while ThreadPoolExecutor::loop // has caught `...` as well all along. Three doc claims on this class depended on diff --git a/tests/test_file_io_ops.cpp b/tests/test_file_io_ops.cpp index 6d2a9b1f3..f01c12c7f 100644 --- a/tests/test_file_io_ops.cpp +++ b/tests/test_file_io_ops.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// Direct coverage of the free functions morph#530/#532 added to +// Direct coverage of the free functions in // `morph/core/file_io_ops.hpp`. They are reached indirectly through // `FileActionLog`/`FileOfflineQueue`/`SqliteOfflineQueue` elsewhere, but only // along the paths those classes happen to take -- which left the error @@ -173,7 +173,7 @@ TEST_CASE("morph::core::rollBackShortWrite: a failing flush truncates nothing", // the write short. The on-disk contents are then unknowable and the // buffered bytes cannot portably be discarded, so truncating to a stream // offset that may exceed the real size would pad the file with NULs rather - // than trim it -- the bricking morph#530 exists to prevent. + // than trim it -- the bricking the rollback exists to prevent. auto const path = tempIoPath("failing_flush"); { std::ofstream out{path, std::ios::binary}; diff --git a/tests/test_file_offline_queue.cpp b/tests/test_file_offline_queue.cpp index a205d06de..4df23b583 100644 --- a/tests/test_file_offline_queue.cpp +++ b/tests/test_file_offline_queue.cpp @@ -460,7 +460,7 @@ TEST_CASE("morph::offline::FileOfflineQueue: construction throws if the compacti REQUIRE_THROWS_AS(morph::offline::FileOfflineQueue(path), std::runtime_error); } -// ── FileIoOps fault injection (LASTRADA-Software/morph#97) ───────────────── +// ── FileIoOps fault injection ───────────────────────────────────────────── // // Same seam FileActionLog's own fault-injection tests use (morph/core/ // file_io_ops.hpp) -- FileOfflineQueue has the identical class of gap: @@ -506,11 +506,9 @@ TEST_CASE("morph::offline::FileOfflineQueue::enqueue: a short fwrite() to the ap std::filesystem::remove(path); } -TEST_CASE( - "morph::offline::FileOfflineQueue::enqueue: a short write does not brick the queue for the next enqueue " - "(morph#530)", - "[file_queue][fault-injection]") { - // Regression for morph#530: writeLine() used to throw on a short write +TEST_CASE("morph::offline::FileOfflineQueue::enqueue: a short write does not brick the queue for the next enqueue", + "[file_queue][fault-injection]") { + // writeLine() must not throw on a short write // without rolling the file back. The handle is append-mode, so the next // successful write concatenated directly onto the truncated JSON with no // separating newline -- merging two records into one line that load() @@ -622,7 +620,7 @@ TEST_CASE( std::filesystem::remove(path); } -// ── Directory fsync (morph#532) ────────────────────────────────────────── +// ── Directory fsync ────────────────────────────────────────── // // compact() renames a temp file onto `_path` on every construction -- a // directory mutation that its own fsync of the temp file's *data* never @@ -630,7 +628,7 @@ TEST_CASE( // after that rename, with the right directory, and that a failure there is // surfaced rather than swallowed. -TEST_CASE("morph::offline::FileOfflineQueue: construction syncs the containing directory after compacting (morph#532)", +TEST_CASE("morph::offline::FileOfflineQueue: construction syncs the containing directory after compacting", "[file_queue][fault-injection]") { auto path = tempQueuePath(); std::vector syncedPaths; @@ -689,7 +687,7 @@ TEST_CASE("morph::offline::FileOfflineQueue: a failing fflush() during construct std::filesystem::remove(path); } -// ── Coverage: maxDepth / overflow policy (morph#112) ─────────────────────── +// ── Coverage: maxDepth / overflow policy ─────────────────────── TEST_CASE("morph::offline::FileOfflineQueue: enqueue at maxDepth throws OfflineQueueFullError", "[file_queue][overflow]") { @@ -818,8 +816,7 @@ TEST_CASE("morph::offline::FileOfflineQueue: the idempotency-key contract surviv std::filesystem::remove(path); } -TEST_CASE("morph::offline::FileOfflineQueue: a NUL-bearing payload and key round-trip intact (morph#531)", - "[file_queue]") { +TEST_CASE("morph::offline::FileOfflineQueue: a NUL-bearing payload and key round-trip intact", "[file_queue]") { auto path = tempQueuePath(); std::filesystem::remove(path); auto const open = [&path] { return std::make_unique(path); }; @@ -827,7 +824,7 @@ TEST_CASE("morph::offline::FileOfflineQueue: a NUL-bearing payload and key round std::filesystem::remove(path); } -// ── An unreadable queue file must not be committed away (morph#494) ── +// ── An unreadable queue file must not be committed away ── // // load() read with an unchecked ifstream and the constructor calls compact() // straight after, so a failed read produced an empty `_items` that compact() @@ -869,7 +866,7 @@ TEST_CASE("FileOfflineQueue: an unreadable queue file is not silently compacted } #endif // _WIN32 -// ── The rollback must cover the flush, not only a short fwrite (morph#530) ── +// ── The rollback must cover the flush, not only a short fwrite ── // // A queue record is far smaller than BUFSIZ, so fwrite is a memcpy into the // stdio buffer and returns the full count even when the disk is full; the @@ -878,7 +875,7 @@ TEST_CASE("FileOfflineQueue: an unreadable queue file is not silently compacted // manifestation of ENOSPC, and a truncated line stayed on disk exactly where // the next writeLine would resume. -TEST_CASE("morph::offline::FileOfflineQueue: a failing fflush rolls the partial record back (morph#530)", +TEST_CASE("morph::offline::FileOfflineQueue: a failing fflush rolls the partial record back", "[file_queue][fault-injection]") { auto path = tempQueuePath(); std::filesystem::remove(path); @@ -927,14 +924,14 @@ TEST_CASE("morph::offline::FileOfflineQueue: a failing fflush rolls the partial std::filesystem::remove(path); } -// ── A directory fsync this platform cannot do is not a failure (morph#532) ── +// ── A directory fsync this platform cannot do is not a failure ── // // fsync on a directory fd needs a *read* handle on it, a strictly stronger // permission than writing a file inside it, and several mounts do not implement // it at all. Treating either as fatal made this class unconstructible on // layouts where it had always worked. -TEST_CASE("morph::offline::FileOfflineQueue: an unsupported directory fsync warns instead of throwing (morph#532)", +TEST_CASE("morph::offline::FileOfflineQueue: an unsupported directory fsync warns instead of throwing", "[file_queue][fault-injection]") { auto path = tempQueuePath(); std::filesystem::remove(path); @@ -964,7 +961,7 @@ TEST_CASE("morph::offline::FileOfflineQueue: an unsupported directory fsync warn std::filesystem::remove(path); } -TEST_CASE("morph::offline::FileOfflineQueue: a genuine directory-fsync failure still throws (morph#532)", +TEST_CASE("morph::offline::FileOfflineQueue: a genuine directory-fsync failure still throws", "[file_queue][fault-injection]") { // The other side of the case above: EIO is a real durability failure and // must not be downgraded to a warning along with the unsupported ones. @@ -977,7 +974,7 @@ TEST_CASE("morph::offline::FileOfflineQueue: a genuine directory-fsync failure s std::filesystem::remove(path); } -TEST_CASE("morph::offline::FileOfflineQueue: a failing fsync rolls the record back too (morph#530)", +TEST_CASE("morph::offline::FileOfflineQueue: a failing fsync rolls the record back too", "[file_queue][fault-injection]") { // The third of writeLine's three failure points. fsync failing after a // successful flush means the bytes are in the page cache but may not reach @@ -1026,7 +1023,7 @@ TEST_CASE("morph::offline::FileOfflineQueue: a failing fsync rolls the record ba #ifndef _WIN32 TEST_CASE("morph::offline::FileOfflineQueue: a mid-read I/O error throws rather than committing an empty queue", "[file_queue][fault-injection]") { - // morph#494's other half. load() reads with its own ifstream and the + // The other half of the unreadable-file rule. load() reads with its own ifstream and the // constructor calls compact() straight after, so a read that fails partway // would otherwise commit an empty set over the real backlog -- constructor // returning normally, queue reporting no pending work. A directory stands @@ -1047,7 +1044,7 @@ TEST_CASE("morph::offline::FileOfflineQueue: a mid-read I/O error throws rather TEST_CASE("morph::offline::FileOfflineQueue: a rollback that cannot truncate refuses every later write", "[file_queue][fault-injection]") { - // The hole morph#530's rollback left open. When the disk that made the + // The hole the rollback leaves open. When the disk that made the // write short is still full, `rollBackShortWrite` deliberately truncates // nothing and a partial record stays at the end of the file. `load()` // tolerates that *only* while it is the trailing line. If the same live diff --git a/tests/test_forms_boolean_anyof_wire.cpp b/tests/test_forms_boolean_anyof_wire.cpp index 081a50ae8..b48e9fe3a 100644 --- a/tests/test_forms_boolean_anyof_wire.cpp +++ b/tests/test_forms_boolean_anyof_wire.cpp @@ -1,7 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // -// The C++ half of morph#189. DynamicForm used to emit a boolean field and an -// `anyOf` integer field as JSON *strings*; this file pins the two facts that +// The C++ half of the renderer's numeric-encoding contract. A renderer that +// emits a boolean field or an `anyOf` integer field as a JSON *string* is +// rejected by the decoder; this file pins the two facts that // make that a defect rather than a cosmetic difference: // // 1. `schemaJson()` really does emit `{"type":"boolean"}` for a `bool` @@ -91,7 +92,7 @@ TEST_CASE("morph::core: fromJson accepts the bare literals the fixed renderer em } TEST_CASE("morph::core: fromJson rejects the quoted literals the renderer used to emit", "[forms][boolean]") { - // These are the payloads morph#189 measured as rejected. If glaze ever + // These are the payloads measured as rejected. If glaze ever // started coercing them, the renderer defect would stop being observable // end-to-end and this test would tell us the severity had changed. SECTION("a stringified boolean") { diff --git a/tests/test_forms_dom_access.cpp b/tests/test_forms_dom_access.cpp index 7b53da57c..3804244ba 100644 --- a/tests/test_forms_dom_access.cpp +++ b/tests/test_forms_dom_access.cpp @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // // `morph::forms::detail::findMember` -- the checked read over a -// `glz::generic_u64` object node (morph#706). +// `glz::generic_u64` object node. // // The point of this file is the *negative* case, and it is the one no amount // of reading the schema output would catch. glaze's `generic_json::at(key)` is diff --git a/tests/test_forms_exact_bounds.cpp b/tests/test_forms_exact_bounds.cpp index ca7362d39..3c0cd6283 100644 --- a/tests/test_forms_exact_bounds.cpp +++ b/tests/test_forms_exact_bounds.cpp @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // // `x-exactMinimum`/`x-exactMaximum`: exact decimal companions for a numeric bound -// a double cannot hold (morph#213). +// a double cannot hold. // // `mergeSchemaExtras` already reads the schema in u64 number mode so int64 // bounds are not rounded on the C++ side. They are rounded anyway the moment a @@ -86,7 +86,8 @@ TEST_CASE("schemaJson emits an exact text companion for a uint64 maximum", "[for TEST_CASE("schemaJson leaves bounds a double holds exactly untouched", "[forms][bounds]") { // The reason this is not emitted unconditionally: an ordinary schema loses - // nothing to a double, and stays byte-for-byte what it was before #213. + // nothing to a double, and stays byte-for-byte what it would be without + // the companion keys. // // This is named for a boundary and does not reach it: EBNarrowAction's // field is a std::int32_t, whose type-range bound (+-2^31) sits 22 binary @@ -95,7 +96,7 @@ TEST_CASE("schemaJson leaves bounds a double holds exactly untouched", "[forms][ // compare against. A schema whose bound is anywhere in that 4-quintillion- // wide interior would pass this case unchanged no matter where the real // comparison's edge sits -- it proves "well inside", not "at the edge" - // (morph#484). The four cases below pin the edge itself, in the + // The four cases below pin the edge itself, in the // tests/test_wire_hardening.cpp style: a control at the limit (no // companion) and one step past it on each side (a companion, with the // exact digits). diff --git a/tests/test_forms_field_bounds.cpp b/tests/test_forms_field_bounds.cpp index 0bef3d422..fdb7834ee 100644 --- a/tests/test_forms_field_bounds.cpp +++ b/tests/test_forms_field_bounds.cpp @@ -1,7 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // // Per-field scalar bounds: `FieldMeta::minimum` / `::maximum` / `::multipleOf` -// (morph#310). // // The rule vocabulary compares a field to *another field*; only `equals` // accepts a literal, and it expresses equality alone. So "this quantity is at diff --git a/tests/test_forms_instance_constraints.cpp b/tests/test_forms_instance_constraints.cpp index 8b9106fc6..b50b0ad77 100644 --- a/tests/test_forms_instance_constraints.cpp +++ b/tests/test_forms_instance_constraints.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// `morph::forms::InstanceConstraints` — the per-instance seam (issue #164). +// `morph::forms::InstanceConstraints` — the per-instance seam. // // The case under test is the issue's own: two *instances* of one compiled // action type declare three and one decimal places, and a value outside the @@ -278,7 +278,7 @@ TEST_CASE("One value can break more than one declared key at once", "[forms][ins // comfortably inside (8) or outside (-5, 40, 80) it; none lands on 0 or 10 // themselves, which is where `<=>`'s `less`/`greater` arms actually change // answer -- the same "name claims a boundary, body tests the middle" shape -// morph#484 named in test_forms_exact_bounds.cpp and +// called out in test_forms_exact_bounds.cpp and // tests/test_wire_hardening.cpp. minimum/maximum are documented inclusive // (checkValue's own doc comment), so a value equal to either bound must pass, // and the smallest possible step past it must not. @@ -426,7 +426,7 @@ TEST_CASE("checkValue with no declared decimalPlaces never reports PrecisionExce } // --------------------------------------------------------------------------- -// The renderer half of the seam (morph#164). +// The renderer half of the seam. // // Serving an instance's keys and checking against them from one declaration is // only worth anything if the shipped renderer honours what was served. It diff --git a/tests/test_forms_layout.cpp b/tests/test_forms_layout.cpp index 1b681a219..236794d5c 100644 --- a/tests/test_forms_layout.cpp +++ b/tests/test_forms_layout.cpp @@ -263,7 +263,7 @@ TEST_CASE("Forms::SchemaJson::NoFieldSpansEmitsNoColspan", "[forms][layout]") { } // --------------------------------------------------------------------------- -// x-submitMode — the emitter half of explicit submit mode (morph#208) +// x-submitMode — the emitter half of explicit submit mode // // The renderer has consumed `x-submitMode` since it shipped, but nothing in // C++ emitted it, so no *generated* schema could carry it and ten call sites diff --git a/tests/test_forms_rule_agreement.cpp b/tests/test_forms_rule_agreement.cpp index db8aa16e2..2ee88a01a 100644 --- a/tests/test_forms_rule_agreement.cpp +++ b/tests/test_forms_rule_agreement.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// The compiled half of the x-rules agreement check (morph#176). +// The compiled half of the x-rules agreement check. // // `x-rules` is evaluated twice — here by `morph::forms::allRulesSatisfied`, and // again in JavaScript by `src/qt/forms/qml/DynamicForm.qml` — and nothing pinned diff --git a/tests/test_forms_rule_corpus.cpp b/tests/test_forms_rule_corpus.cpp index fe43edf65..35966b75e 100644 --- a/tests/test_forms_rule_corpus.cpp +++ b/tests/test_forms_rule_corpus.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// The compiled half of the shared `x-rules` corpus (morph#176). +// The compiled half of the shared `x-rules` corpus. // // `x-rules` is evaluated twice — here by `morph::forms::allRulesSatisfied`, // and again in JavaScript by `src/qt/forms/qml/DynamicForm.qml`. Before this diff --git a/tests/test_forms_rules.cpp b/tests/test_forms_rules.cpp index 538a541d7..d74075279 100644 --- a/tests/test_forms_rules.cpp +++ b/tests/test_forms_rules.cpp @@ -412,7 +412,7 @@ TEST_CASE("Forms::Rules::Factories::ProduceGenuinelyExecutedInstantiations", "[f // --------------------------------------------------------------------------- // Unsatisfiable declarations: a capping rule over fields `required` also -// demands (issue #165). Every fixture below uses CFRMoney -- an +// demands. Every fixture below uses CFRMoney -- an // EmptyCapableField, hence *required by default* -- rather than // std::optional: `isStdOptional` keeps a std::optional member out of // `required` on sight, so a std::optional-typed fixture could not produce the @@ -552,7 +552,7 @@ TEST_CASE("Forms::Rules::Unsatisfiable::StdOptionalMembersCanNeverConflict", "[f // CFRContactForm's exactlyOneOf ranges over two std::optional members, // which isStdOptional keeps out of `required` on sight -- so the // contradiction is unreachable for them, with or without the check. Pinned - // because a fixture like this one is the easy wrong way to test #165. + // because a fixture like this one is the easy wrong way to test it. std::string schema{}; CHECK_NOTHROW(schema = morph::forms::schemaJson()); CHECK(schema.find(R"("required":[])") != std::string::npos); @@ -583,7 +583,7 @@ TEST_CASE("Forms::Rules::Equals::PlainScalarField", "[forms][rules]") { } TEST_CASE("Forms::Rules::Equals::EmitNodeCarriesExactDigitsForLargeNegativeLiteral", "[forms][rules]") { - // morph#213's class of bug, the unexplored negative side: the existing + // The exact-digits class of bug, on its negative side: the existing // "an int64 literal beyond 2^53" coverage (test_forms_rule_agreement.cpp's // RuleAgreementAction, 9007199254740993) only ever exercises the // std::cmp_greater(...) arm of Equals::emitNode()'s @@ -872,7 +872,7 @@ TEST_CASE("Forms::Rules::And::VariadicAcceptsMoreThanTwoConditions", "[forms][ru } // --------------------------------------------------------------------------- -// The condition vocabulary is closed, and a wrapper does not open it (#544). +// The condition vocabulary is closed, and a wrapper does not open it. // // `andOf`/`orOf`/`notOf` and the three `when`-bearing rules used to admit any // node exposing `test(const A&) const noexcept`, which every rule node does. @@ -942,8 +942,8 @@ template concept CFREqualsStringLiteralComposable = requires { morph::forms::equals(Field, "URGENT"); }; // The marker is enforced at every position a condition is accepted. Each of -// these compiled before #544, and each emitted a node no renderer's condition -// vocabulary has a case for. +// these compiles without the marker, and each emits a node no renderer's +// condition vocabulary has a case for. static_assert(!CFRAndComposable); static_assert(!CFROrComposable); static_assert(!CFRNotComposable); @@ -983,7 +983,7 @@ static_assert(!CFREqualsStringLiteralComposable<&CFRConditionMarkerForm::promo>) } // namespace // --------------------------------------------------------------------------- -// Unsatisfiability detection reaches inside a compound node (#544 part a). +// Unsatisfiability detection reaches inside a compound node. // // The top-level `x-rules` array is a conjunction -- `allRulesSatisfied` folds // it with `&&` -- and so is an `and` node's `conditions`, so a contradiction in @@ -1042,8 +1042,8 @@ struct CFRCappingUnderOrIsSatisfiable { TEST_CASE("Forms::Rules::Unsatisfiable::CappingRuleWrappedInAndOfStillThrows", "[forms][rules][unsatisfiable]") { // The same contradiction as CFRUnsatisfiableExactlyOne, one `andOf` deep. - // It shipped silently before #544, because the check skipped any node with - // no `fields` key and `and` emits `conditions` instead. + // A check that skips any node with no `fields` key misses it, because + // `and` emits `conditions` instead. CHECK_THROWS_AS(morph::forms::schemaJson(), morph::forms::UnsatisfiableFormError); } diff --git a/tests/test_journal_payload_evolution.cpp b/tests/test_journal_payload_evolution.cpp index d2834d684..9e1e303c0 100644 --- a/tests/test_journal_payload_evolution.cpp +++ b/tests/test_journal_payload_evolution.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// Coverage for journal payload evolution (issue #174): the payload schema +// Journal payload evolution: the payload schema // fingerprint (morph::model::payloadFingerprint, core/payload_schema.hpp), // LogEntry::schema, ActionDispatcher::schemaFor, and replay()'s mismatch gate // with its migration seam. @@ -102,7 +102,7 @@ struct PEShapeNested { std::map lookup; }; -// ── Custom-codec fixtures (issue #245) ─────────────────────────────────────── +// ── Custom-codec fixtures ─────────────────────────────────────────────────── // // A unit system of this file's own: `UnitTraits` is specialised per enum, and // two test translation units linking into one binary must not both specialise @@ -145,7 +145,7 @@ struct PESpecialFields { morph::util::Tagged id; }; -// The retype #245 filed: two custom-codec fields swapped for one another. +// The retype that matters: two custom-codec fields swapped for one another. struct PESpecialFieldsRetyped { morph::time::Timestamp amount; morph::math::Rational at; @@ -499,7 +499,7 @@ TEST_CASE("journal::replay: a migration never rewrites the stored entry", "[jour REQUIRE(entries.front().schema == recordedSchema); } -// ── Custom-codec types are distinguished from one another (issue #245) ─────── +// ── Custom-codec types are distinguished from one another ─────────────────── // // Every type carrying its own `glz::meta` used to render as the single opaque // tag `x`, so swapping `Rational` for `Timestamp` in a recorded action changed diff --git a/tests/test_logger.cpp b/tests/test_logger.cpp index a6400a365..38253effa 100644 --- a/tests/test_logger.cpp +++ b/tests/test_logger.cpp @@ -251,7 +251,7 @@ TEST_CASE("concurrent log calls are thread-safe", "[logger]") { REQUIRE(count.load() == numThreads * msgsPerThread * 4); } -// ── The noexcept guarantee (morph#158) ──────────────────────────────────────── +// ── The noexcept guarantee ────────────────────────────────────────────────── // Compile-time, and the part that cannot rot: if any entry point loses // `noexcept`, this fails to build rather than failing subtly at some call site @@ -285,8 +285,8 @@ TEST_CASE("morph::log: a throwing sink does not propagate to the caller", "[logg }); morph::log::setLogLevel(morph::log::LogLevel::debug); - // Every public entry point, both overload families. Before morph#158 each - // of these unwound into the caller. + // Every public entry point, both overload families. Without the guarantee + // each of these unwinds into the caller. REQUIRE_NOTHROW(morph::log::logDebug("plain")); REQUIRE_NOTHROW(morph::log::logInfo("plain")); REQUIRE_NOTHROW(morph::log::logWarn("plain")); diff --git a/tests/test_nested_forms.cpp b/tests/test_nested_forms.cpp index 9cc32cb54..6762ee3cd 100644 --- a/tests/test_nested_forms.cpp +++ b/tests/test_nested_forms.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// Coverage for issue #25: form generation recurses into a nested-aggregate +// Form generation recurses into a nested-aggregate // member's object schema -- a directly-nested struct member or a // `std::vector` repeated aggregate -- applying the same // title/x-order/required/widget rules the top level already applies, instead @@ -11,7 +11,7 @@ // shared `$defs` entry (referenced by `$ref`) when it is used two or more // times. Both are exercised below. Recursion continues into the type graph to // whatever depth it has: there is no depth limit, and a self- or mutually- -// referential type is described rather than rejected (morph#703 -- see +// referential type is described rather than rejected (see // docs/spec/forms/forms.md, "Nested aggregates (recursive, cycle-safe)"). Both // of those cases are exercised at the bottom of this file. What *is* bounded // is what a given compiler will instantiate: see `kDeepChainLevels` below for @@ -96,8 +96,8 @@ struct DeepSpecimen { Provenance provenance; }; -// A self-referential nested-aggregate type (a tree node). Until morph#703 this -// could not be passed to morph::forms::schemaJson() at all -- either use, +// A self-referential nested-aggregate type (a tree node). A recursion carrying +// a depth NTTP cannot pass this to morph::forms::schemaJson() at all -- either use, // as the action type or nested inside one, tripped forms.hpp's kMaxNestDepth // static_assert. It now can be, and is: see "Cyclic nested-aggregate types" // at the bottom of this file. @@ -122,8 +122,8 @@ struct Bee { std::vector ays; }; -// An acyclic chain four levels past the old 16-level cap, which is the other -// thing morph#703 removed. Written out rather than macro-generated so the +// An acyclic chain four levels past the 16-level cap a depth counter would +// impose. Written out rather than macro-generated so the // fixture reads as what it is. How much of it each toolchain can actually // compile is decided at `DeepChain` below -- and it is not the same number on // all three. @@ -192,7 +192,7 @@ struct Deep20 { }; // How deep the chain the deep-nesting case actually uses is. Not a morph -// limit -- morph has had none since morph#703 -- but a *compiler* one, and it +// limit -- morph imposes none -- but a *compiler* one, and it // is MSVC's. `mergeSchemaExtras` default-constructs `A probe{}`, and for a // chain-rooted action that one initialiser is as deeply nested as the chain // is; past a point cl gives up at that line with @@ -200,7 +200,7 @@ struct Deep20 { // fatal error C1054: compiler limit: initializers nested too deeply // // which names neither the action type nor the nesting, and so is strictly -// worse than the static_assert morph#703 removed. Measured rather than +// worse than a static_assert naming the limit. Measured rather than // guessed, on cl 19.44 / 19.50 / 19.51 (CI runs 19.51.36256.0), by bisecting a // reduced `template void f() { A probe{}; }` over a chain of // plain aggregates -- see docs/spec/forms/forms.md, "Nesting depth in @@ -215,9 +215,9 @@ struct Deep20 { // The action type is itself one of cl's 15 levels, so cl tops out at a // 14-link chain below it. 12 is what this fixture keeps there: one link of // margin, because forms.hpp default-constructs a probe at four sites and a -// future one could add a wrapper level. That is *below* the 16-level cap -// morph#703 removed, so on MSVC this case no longer demonstrates what it was -// written to demonstrate -- it still proves the walk descends and annotates +// future one could add a wrapper level. That is *below* the 16-level cap a +// depth counter would impose, so on MSVC this case does not demonstrate the +// absence of that cap -- it still proves the walk descends and annotates // every level, which is the part that can regress. The 20-level case is real // coverage on the other three CI legs (Linux gcc, Linux clang, Windows // clang-cl), and reducing it to 12 everywhere would have deleted that @@ -662,14 +662,14 @@ TEST_CASE("Forms::SchemaJson::NestedAggregate: optionalFields marks a non-std::o CHECK(std::find(requiredNames.begin(), requiredNames.end(), "label") == requiredNames.end()); } -// ── annotateNestedAggregateRef's defensive fallbacks (issue #25) ─────────── +// ── annotateNestedAggregateRef's defensive fallbacks ────────────────────── // // These call the detail function directly with hand-built DOM fragments, // rather than through schemaJson(), because glaze itself never actually // produces the malformed shapes these branches guard against -- see the // function's own doc comment ("left untouched rather than guessed at"). // -// The recursion carries no depth NTTP since morph#703 -- only the `visited` +// The recursion carries no depth NTTP -- only the `visited` // set, the shared-$defs bookkeeping it threads through. A fresh, empty set per // call is what mergeSchemaExtras hands the recursion at the start of each // schema. @@ -789,10 +789,10 @@ TEST_CASE("Forms::SchemaJson::NestedAggregate: a self-referential nested-aggrega CHECK(decoded.children[0].name == "child"); } -// ── Cyclic nested-aggregate types (morph#703) ────────────────────────────── +// ── Cyclic nested-aggregate types ───────────────────────────────────────── // -// Every case below was a hard `static_assert` before morph#703 removed the -// `Depth` NTTP, `kMaxNestDepth` and the 16-level cap: the *compilation* of +// Every case below is a hard `static_assert` for a recursion carrying a +// `Depth` NTTP, `kMaxNestDepth` and a 16-level cap: the *compilation* of // this section is therefore itself the regression test, and reinstating the // NTTP turns these into build failures rather than assertion failures. The // assertions on top of that pin the shape of what is emitted, so a change that @@ -901,7 +901,7 @@ TEST_CASE("Forms::SchemaJson::NestedAggregate: a mutually referential pair yield TEST_CASE("Forms::SchemaJson::NestedAggregate: a deep acyclic chain compiles and is annotated at every level", "[forms][nested][issue703]") { - // 20 levels -- four past the cap morph#703 removed -- everywhere except + // 20 levels -- four past the cap a depth counter would impose -- everywhere except // MSVC, where cl's own 15-level initialiser-nesting limit caps it at 12: // see `kDeepChainLevels`. The name no longer says "past the old 16-level // cap" because on one of the four CI legs that is not what runs. diff --git a/tests/test_offline_queue.cpp b/tests/test_offline_queue.cpp index b9d2752e2..4866c33e0 100644 --- a/tests/test_offline_queue.cpp +++ b/tests/test_offline_queue.cpp @@ -198,7 +198,7 @@ TEST_CASE("morph::offline::IOfflineQueue: default setAttempts is a no-op", "[que REQUIRE(queue.items[0].attempts == 0); } -// ── Coverage: maxDepth / overflow policy (morph#112) ─────────────────────── +// ── Coverage: maxDepth / overflow policy ─────────────────────── TEST_CASE("morph::offline::InMemoryOfflineQueue: enqueue below maxDepth succeeds", "[queue][overflow]") { morph::offline::InMemoryOfflineQueue queue{3}; @@ -289,8 +289,7 @@ TEST_CASE("morph::offline::InMemoryOfflineQueue: IOfflineQueue idempotency-key c [] { return std::make_unique(); }); } -TEST_CASE("morph::offline::InMemoryOfflineQueue: a NUL-bearing payload and key round-trip intact (morph#531)", - "[offline_queue]") { +TEST_CASE("morph::offline::InMemoryOfflineQueue: a NUL-bearing payload and key round-trip intact", "[offline_queue]") { morph::test::checkNulPayloadRoundTrip("InMemoryOfflineQueue", [] { return std::make_unique(); }); } diff --git a/tests/test_opaque_model_ids.cpp b/tests/test_opaque_model_ids.cpp index 9c8858e4c..2b7c7059c 100644 --- a/tests/test_opaque_model_ids.cpp +++ b/tests/test_opaque_model_ids.cpp @@ -93,7 +93,7 @@ TEST_CASE("OpaqueIdGenerator is a bijection: 20000 counters produce 20000 distin REQUIRE(seen.size() == n); } -// ── morph#453: the bijection above cannot see the high 32 bits ── +// ── The bijection above cannot see the high 32 bits ── // // Counters 1..20000 all have a zero high half, so the case above passes whether // or not `permute` uses `counter >> 32` at all -- it cannot distinguish a diff --git a/tests/test_outbox.cpp b/tests/test_outbox.cpp index f744e6901..e40fa2e6d 100644 --- a/tests/test_outbox.cpp +++ b/tests/test_outbox.cpp @@ -393,8 +393,8 @@ TEST_CASE("OutboxRelay::relay(): a null sink reaching a non-empty drain throws N "[outbox][relay]") { // Unlike the empty-drainOutbox case above (which stays on relay()'s // early-return path and never reaches sink at all), this drains a real - // row with sink still null -- morph#95's actual gap: sink->append(row) - // on a null shared_ptr used to be a null-pointer virtual + // row with sink still null -- the actual gap: sink->append(row) + // on a null shared_ptr is a null-pointer virtual // dispatch (real UB, a process crash, not a catchable exception). relay() // now throws NullSinkError before ever dereferencing sink. std::vector logged; diff --git a/tests/test_principal.cpp b/tests/test_principal.cpp index 68bc700e4..ce134be90 100644 --- a/tests/test_principal.cpp +++ b/tests/test_principal.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// Coverage for issue #24: morph::session::Principal and Bridge::setPrincipal/ +// morph::session::Principal and Bridge::setPrincipal/ // currentPrincipal -- readable authorization state outside a dispatch, so UI // code can gate itself (e.g. disable a button) instead of attempting an // action and catching the refusal. @@ -66,7 +66,7 @@ TEST_CASE("morph::bridge::Bridge::setPrincipal: readable without an active dispa "[bridge][principal]") { // No BridgeHandler, no execute() call anywhere in this test -- proves the // Principal is readable purely from the Bridge, independent of any - // in-flight or prior dispatch. This is exactly the gap issue #24 reports: + // in-flight or prior dispatch. That is the whole point of the type: // session::current() (Context) only exists during a dispatch; Principal // does not have that restriction. morph::exec::ThreadPoolExecutor pool{2}; diff --git a/tests/test_quantity.cpp b/tests/test_quantity.cpp index 352d5c2d1..17671d521 100644 --- a/tests/test_quantity.cpp +++ b/tests/test_quantity.cpp @@ -654,7 +654,7 @@ TEST_CASE("NamedQuantity slices to a plain Quantity", "[quantity]") { CHECK_FALSE(blank.hasValue()); } -// ── morph#496: rendering an un-canonicalised INT64_MIN numerator ── +// ── Rendering an un-canonicalised INT64_MIN numerator ── // // formatRationalDecimal negated the numerator with signed arithmetic, which is // UB for INT64_MIN -- confirmed by UBSan at quantity.hpp:101 before the fix. @@ -671,7 +671,7 @@ TEST_CASE("formatRationalDecimal: an un-canonicalised INT64_MIN numerator render REQUIRE(morph::units::detail::formatRationalDecimal(value) == "-9223372036854775808"); } -// ── morph#574: a deep derivation chain must not overflow the stack ── +// ── A deep derivation chain must not overflow the stack ── // // `total = total + one` in a loop records one ASTNode per iteration, chained // through `left`, and nothing collapses the chain. Both walks over it used to @@ -706,7 +706,7 @@ constexpr int kDeepChainNodes = 100000; // margin, which is what keeps it from passing vacuously in an optimised build. // It used to be priced as well -- rendering the chain in full was quadratic in // the depth, 27.7 s at this size under ASan+UBSan and over ctest's 120 s -// timeout under TSan (morph#589) -- but since morph#582 `combine` appends to +// timeout under TSan -- but `combine` appends to // its left operand instead of copying it, and the same render costs 0.11 s // under ASan+UBSan and 1.3 s at -O0 under TSan. constexpr int kDeepEquationNodes = 70000; @@ -742,7 +742,7 @@ TEST_CASE("equation() walks a 70000-node provenance chain without overflowing th // `kEquationStepsUnlimited`, not the default limit, and that is what keeps // this test load-bearing: under the default the renderer stops 100 steps // in and never walks deep enough to have overflowed anything, so it would - // pass against the recursive code this test exists to catch (morph#582). + // pass against the recursive code this test exists to catch. // The chain is still rendered in full here -- 350,001 characters of it. auto const lines = total.equation(morph::units::kEquationStepsUnlimited); // Formula, substitution, result, and one `where` line: the single `one` @@ -754,7 +754,7 @@ TEST_CASE("equation() walks a 70000-node provenance chain without overflowing th CHECK(lines[3] == "where c1 = 1"); } -// ── morph#582: the rendered derivation is bounded, the walk is not ── +// ── The rendered derivation is bounded, the walk is not ── // // Two separate things, and the tests below hold them apart. The **step limit** // bounds what `equation()` writes out (the first two cases); the **append in @@ -844,14 +844,14 @@ TEST_CASE("equation()'s step limit is the caller's to set", "[quantity][provenan } } -// ── morph#602: the label walk must count nodes, not root-to-leaf paths ── +// ── The label walk must count nodes, not root-to-leaf paths ── TEST_CASE("equation() renders a derivation shared along many paths in node time", "[quantity][provenance][equation][morph602]") { // `q = q + q` forty times: 41 distinct nodes, 2^40 root-to-leaf paths. // Before `assignLabels` carried a visited set the walk was per-path, and - // the measured curve (0.099 s at 25 nodes, x4 per node, morph#602) puts + // the measured curve (0.099 s at 25 nodes, x4 per node) puts // this run at hours -- so the failure signal here is ctest's 120 s timeout, - // as the morph#574 cases' is a segfault. The assertions below cannot tell + // as the deep-chain cases' is a segfault. The assertions below cannot tell // the two implementations apart; the clock is what does. Euro q{Rational{Numerator{1}, Denominator{1}, DecimalPlaces{2}}}; for (int i = 0; i < 40; ++i) { diff --git a/tests/test_quantity_forms.cpp b/tests/test_quantity_forms.cpp index 09385d91d..b4b47ae09 100644 --- a/tests/test_quantity_forms.cpp +++ b/tests/test_quantity_forms.cpp @@ -172,7 +172,7 @@ struct QFSchedule { [[nodiscard]] bool validate() const { return morph::forms::allRequiredEngaged(*this); } }; -// Three `Choice` fields whose payload types differ (morph#543) -- and, with +// Three `Choice` fields whose payload types differ -- and, with // them, the options action each names, which is what the composed `$defs` key // keeps apart. Not registered as an action anywhere -- only its generated // schema is under test. @@ -191,8 +191,8 @@ struct QFTwinSlots { // Two `Choice` fields whose value/label names split the same characters // differently. A `_`-joined key that did not escape the `_` inside a part // would spell both `Choice_QFListRows_id_x_name`, putting the second field -// back on the first one's definition -- morph#543 reached through two -// ordinary snake_case wire names. +// back on the first one's definition -- the shared-`$defs` collision reached +// through two ordinary snake_case wire names. struct QFUnderscoreSplitChoices { morph::forms::Choice left; morph::forms::Choice right; @@ -664,8 +664,8 @@ namespace { } // namespace TEST_CASE("Forms::SchemaJson::DifferentlyTypedChoiceFieldsKeepTheirOwnTypes", "[forms]") { - // Before morph#543 every `Choice<...>` instantiation was named "Choice", - // so glaze populated one `$defs/Choice` entry from whichever it reached + // If every `Choice<...>` instantiation were named "Choice", + // glaze would populate one `$defs/Choice` entry from whichever it reached // first and had the rest `$ref` it: a `bool` picklist next to an // `int64_t` one described the int64 field as a boolean, and // DynamicForm.qml resolves the `$ref` and draws a checkbox for a @@ -1084,7 +1084,7 @@ TEST_CASE("Forms::FieldMeta::FluentBuildersRunAtRuntimeViaNamespaceScopeInlineCo } // --------------------------------------------------------------------------- -// morph#159: `x-decimalPlaces` is an *enforced* contract, so +// `x-decimalPlaces` is an *enforced* contract, so // reconcileDeclaredPrecision has to re-round the stored value, not just move // the precision tag. Before the fix the reproduction below left the payload at // exactly 1.23456 while tagging it dp=1 — the form rendered "1.2" over a stored diff --git a/tests/test_rational.cpp b/tests/test_rational.cpp index 53bbe0cbd..b11907bd0 100644 --- a/tests/test_rational.cpp +++ b/tests/test_rational.cpp @@ -899,19 +899,19 @@ TEST_CASE("Rational::Wire::NullableComposition", "[rational]") { } // --------------------------------------------------------------------------- -// roundToDecimalPlaces — the exact scale-rounding primitive (morph#159). +// roundToDecimalPlaces — the exact scale-rounding primitive. // // `ceil`/`floor`/`trunc` all leave the Rational domain (they return int64_t), // so before this existed there was no way to *reduce a value* to N decimal // places at all — only to relabel it, which is exactly the display-vs-storage -// split morph#159 reports. +// split this primitive closes. // --------------------------------------------------------------------------- using morph::math::RoundingMode; using morph::math::roundToDecimalPlaces; // The primitive is usable in constant expressions, like the rest of Rational's -// arithmetic. These are morph#159's own reproduction value: 1.23456 at dp=5, +// arithmetic. The worked value: 1.23456 at dp=5, // reduced to the dp=1 its field declares. static_assert(roundToDecimalPlaces(Rational{Numerator{123456}, Denominator{100000}, DecimalPlaces{5}}, dp1) == Rational{Numerator{6}, Denominator{5}, dp1}); @@ -919,7 +919,7 @@ static_assert(roundToDecimalPlaces(Rational{Numerator{123456}, Denominator{10000 .getDecimalPlaces() == dp1); TEST_CASE("Rational::RoundToDecimalPlaces::ReducesTheValueNotJustTheTag", "[rational]") { - // morph#159's payload: 1.23456 canonicalises to 3858/3125. + // 1.23456 canonicalises to 3858/3125. Rational const submitted{Numerator{123456}, Denominator{100000}, DecimalPlaces{5}}; REQUIRE(submitted.numerator == 3858); REQUIRE(submitted.denominator == 3125); diff --git a/tests/test_rational_checked.cpp b/tests/test_rational_checked.cpp index 77350b349..25cc9ed09 100644 --- a/tests/test_rational_checked.cpp +++ b/tests/test_rational_checked.cpp @@ -190,7 +190,7 @@ TEST_CASE("checkedMul handles a zero operand without dividing by a zero gcd", "[ } TEST_CASE("checkedDiv reports the overflow dividedBy reports as success", "[rational][checked]") { - // morph#206. Dividing INT64_MAX by 1/1000000 has an exact quotient of + // Dividing INT64_MAX by 1/1000000 has an exact quotient of // 9223372036854775807000000, which does not fit. dividedBy multiplies by // the reciprocal through the saturating path, clamps to INT64_MAX/1, and // hands back a *successful* expected -- so the caller who checks it is @@ -258,8 +258,8 @@ TEST_CASE("checkedDiv checks the cross-cancelled factors, not the raw operands", } TEST_CASE("A saturating division names its own site and its own remedy", "[rational][checked][saturate]") { - // morph#206: dividedBy saturates through operator*='s arithmetic, and the - // log used to say so -- naming a function the division caller never + // dividedBy saturates through operator*='s arithmetic, and a log that says + // so names a function the division caller never // called, and offering checkedAdd/checkedSub/checkedMul, none of which is // a division. Each site now names itself and the one helper that helps. std::vector logged; @@ -424,7 +424,7 @@ TEST_CASE("checked* still report rather than saturate, for callers that must not } TEST_CASE("Saturating arithmetic is noexcept even when the log sink throws", "[rational][checked][saturate]") { - // morph::log offers no noexcept guarantee (morph#158): a user-installed + // morph::log offers no noexcept guarantee: a user-installed // sink may throw, and detail::log's own scoped_lock may throw // std::system_error. An arithmetic operator must not start failing because // logging failed, so the reporters swallow. Without that, these operators diff --git a/tests/test_registration_phase.cpp b/tests/test_registration_phase.cpp index 03448838d..e735c546d 100644 --- a/tests/test_registration_phase.cpp +++ b/tests/test_registration_phase.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// The registration-phase latch (morph#698). +// The registration-phase latch. // // `docs/spec/core/registry.md` ("Thread safety") states as a hard constraint // that the three process-level registries are written only during static diff --git a/tests/test_registration_qualified_types.cpp b/tests/test_registration_qualified_types.cpp index 124be2551..421fdecd3 100644 --- a/tests/test_registration_qualified_types.cpp +++ b/tests/test_registration_qualified_types.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -// Regression test for issue #21: BRIDGE_REGISTER_MODEL / BRIDGE_REGISTER_ACTION built the name +// BRIDGE_REGISTER_MODEL / BRIDGE_REGISTER_ACTION must not build the name // of their generated static registrar by token-pasting the model/action type onto a fixed // prefix (`bridge_model_reg_##M`, `bridge_action_reg_##M##_##A`). That only produces a valid // identifier when both arguments are bare identifiers -- a namespace-qualified type pastes ':' diff --git a/tests/test_registration_same_line.cpp b/tests/test_registration_same_line.cpp index 8181a19ff..35482e76a 100644 --- a/tests/test_registration_same_line.cpp +++ b/tests/test_registration_same_line.cpp @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 -// Regression test for issue #21 (follow-up): keying the generated registrar variable name on -// `__LINE__` fixed the original namespace-qualified-type bug but introduced a new one -- +// Keying the generated registrar variable name on +// `__LINE__` fixes the namespace-qualified-type collision but introduces a new one -- // `__LINE__` is only unique within a single physical file, so two different headers that each // invoke BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION on the same line number produce the same // generated identifier once both are `#include`d into one translation unit. Since C++ unnamed diff --git a/tests/test_registry_schema_forgery.cpp b/tests/test_registry_schema_forgery.cpp index a459411a6..f9d896ded 100644 --- a/tests/test_registry_schema_forgery.cpp +++ b/tests/test_registry_schema_forgery.cpp @@ -92,7 +92,7 @@ struct RegSchemaForgeModel { // specialization of) the primary, glaze-driven template. // // Each specialization returns `const std::string&`, matching the primary -// template (morph#573 step 1). A specialization therefore has to own its text +// template. A specialization therefore has to own its text // for at least as long as the caller reads it, which is what the function-local // statics below are for -- returning a reference to a temporary would dangle. // That is the same contract the primary template meets with its own cache, and diff --git a/tests/test_remote_connection_scope.cpp b/tests/test_remote_connection_scope.cpp index b7dc5acc7..c1fcd05f9 100644 --- a/tests/test_remote_connection_scope.cpp +++ b/tests/test_remote_connection_scope.cpp @@ -1098,7 +1098,7 @@ TEST_CASE("morph::backend::RemoteServer: a shared register on a closed scope is REQUIRE(server->health().liveModels == 0U); } -// ── Issue #48: connection-scoped SimulatedRemoteBackend ───────────────────── +// ── Connection-scoped SimulatedRemoteBackend ──────────────────────────────── // // SimulatedRemoteBackend used to send every register/deregister/attach/assign // through the unscoped two-argument RemoteServer::handle/handleInline, so it diff --git a/tests/test_remote_execute_ordering.cpp b/tests/test_remote_execute_ordering.cpp index 0db45d970..d1b8f87f6 100644 --- a/tests/test_remote_execute_ordering.cpp +++ b/tests/test_remote_execute_ordering.cpp @@ -30,7 +30,7 @@ // `include/morph/core/detail/execute_order_gate.hpp`) is wired into // `include/morph/core/remote.hpp`, whose comments carry its full design // history — including the reverted first attempt. The gate's own internal -// state machine (including the out-of-order-release mechanism, issue #449) +// state machine, including the out-of-order-release mechanism, // has direct, threadless unit coverage in // `tests/test_execute_order_gate.cpp`; what remains here is what only // `RemoteServer`'s real dispatch path can prove. @@ -229,11 +229,11 @@ TEST_CASE( // force three same-model executes into an interleaving meant to reach the // gate's "already gone" defensive branches. // -// It no longer can. That interleaving was reachable before issue #449's fix: -// the gate used to erase a model's map entry the moment the *last* ticket +// It cannot. That interleaving is reachable only if the gate erases a model's +// map entry the moment the *last* ticket // released, even with an earlier ticket still outstanding, which is exactly -// what let a third, later-arriving ticket find the entry gone. Since #449's -// fix (a released-out-of-order ticket is now recorded rather than applied, +// what lets a third, later-arriving ticket find the entry gone. With the +// current rule (a released-out-of-order ticket is recorded rather than applied, // and the entry is erased only once every ticket up to it has released in // order), that specific interleaving can no longer surface a missing entry -- // confirmed directly: instrumenting both defensive branches and re-running @@ -311,7 +311,7 @@ TEST_CASE( "an execute refused by the shutdown gate releases the execute-ordering " "ticket it took, so a later ticket already waiting on it is not stranded", "[remote][execute-ordering][shutdown]") { - // Regression test for #348. `handleImpl` takes the ordering ticket on the + // `handleImpl` takes the ordering ticket on the // transport thread, in send order; `dispatchMessage`'s shutdown gate then // returns *before* `dispatchExecute`, which is the only place a ticket is // released. Because the pool may run the two posted tasks in either order, @@ -569,8 +569,8 @@ TEST_CASE( "a throw out of dispatchExecute releases the execute-ordering ticket it took, " "so a later ticket already waiting on it is not stranded", "[remote][execute-ordering][exceptions]") { - // Regression test for #351, the sibling of the shutdown-gate case above - // (#348): the same stranded ticket, reached by a different route. + // The sibling of the shutdown-gate case above: the same stranded ticket, + // reached by a different route. // `dispatchExecute` has no try/catch of its own, and its rejectAndRelease // helper only covers the *explicit* early returns; an exception unwinds // past all of them into `dispatchMessage`'s outer catch, which replies but @@ -579,7 +579,8 @@ TEST_CASE( // release that follows `_strand.post`, so every exit path releases it, // including ones nobody has thought of yet. // - // The interleaving, forced rather than raced (identical to #348's case): + // The interleaving, forced rather than raced (identical to the + // shutdown-gate case's): // A handle() -> ticket 0; its pool task is intercepted before it runs. // B handle() -> ticket 1; runs, passes every gate, parks in // ExecuteOrderGate::awaitTurn(mid, 1) waiting for ticket 0. @@ -588,7 +589,7 @@ TEST_CASE( // // The three sections below are the three `IAuthorizer` hooks // `dispatchExecute` calls inside the ticketed region. The fourth reachable - // throw site named in #351 -- `missingRequiredFields`, under + // throw site in that region -- `missingRequiredFields`, under // `PayloadCompleteness::RequireDeclaredFields` -- is *not* separately // exercised here: it is not a user-supplied virtual, so forcing a throw // out of it would mean faulting the dispatcher's own parse rather than @@ -722,7 +723,7 @@ namespace { /// Lets a test hold open the window between one caller's `take()` (or /// `takeAndPost`) and its enqueue reaching the real pool, so a second, /// concurrent caller gets every chance to run in between -- without touching -/// production code. See morph#519. +/// production code. class StallFirstPostExecutor : public morph::exec::IExecutor { public: explicit StallFirstPostExecutor(morph::exec::IExecutor& inner) : _inner{inner} {} @@ -785,9 +786,8 @@ class StallFirstPostExecutor : public morph::exec::IExecutor { TEST_CASE("two concurrent handle() callers on one modelId with a pool of one do not deadlock", "[remote][execute-ordering][morph-519]") { - // Regression test for #519 (part of the sweep tracked in #518, finding F1). - // handleImpl used to take an execute-ordering ticket and enqueue the dispatch - // work as two separate, unlocked steps. Two threads calling handle() concurrently + // `handleImpl` must not take an execute-ordering ticket and enqueue the + // dispatch work as two separate, unlocked steps: two threads calling handle() concurrently // for the same model could take tickets in order but enqueue out of order: if the // later ticket's task reached the pool's FIFO queue first, a pool worker picked it // up, called ExecuteOrderGate::awaitTurn and blocked waiting for the earlier @@ -888,9 +888,9 @@ TEST_CASE( "an execute rejected out of ticket order does not strand an earlier ticket " "that has not reached ExecuteOrderGate::awaitTurn yet", "[remote][execute-ordering]") { - // Regression test for #449 -- the third occurrence of the stranded-ticket - // bug class #348 and #351 each closed by making the *release* structural. - // Making release unmissable was necessary and is not sufficient: the + // The third distinct way into the stranded-ticket failure. The other two + // are closed by making the *release* structural. + // Making release unmissable is necessary and is not sufficient: the // remaining hole is in `ExecuteOrderGate::release` itself. // // `ExecuteOrderGate::release(mid, ticket)` used to assign @@ -910,7 +910,7 @@ TEST_CASE( // process's life, `_inFlightExecutes` stuck above zero (so // `drainedWithin()` can never succeed), and `~ThreadPoolExecutor` hanging // forever in join(). That is exactly the reported symptom, and it explains - // why #449 reproduced under `morph::net` in particular: a dropped + // why this reproduces under `morph::net` in particular: a dropped // connection reclaims that connection's models, so the executes still in // flight for one model split into some that find the model and some that // reject with "model not found" -- manufacturing precisely this @@ -985,7 +985,7 @@ TEST_CASE( if (!aCompleted) { // A's pool thread is parked in a wait with no deadline and can never // be joined; leak the fixture rather than hang the whole binary in - // ~ThreadPoolExecutor, exactly as the #348/#351 cases above do. + // ~ThreadPoolExecutor, exactly as the two cases above do. (void)server.get(); // NOLINTBEGIN(bugprone-unused-return-value) -- leaking is the point. (void)gated.release(); diff --git a/tests/test_remote_reply_envelopes.cpp b/tests/test_remote_reply_envelopes.cpp index 802448340..d1e17180b 100644 --- a/tests/test_remote_reply_envelopes.cpp +++ b/tests/test_remote_reply_envelopes.cpp @@ -12,8 +12,8 @@ // The suite reaches all of these branches already, through the bridge, the // shared-instance directory and the limit policy. What it does not do at most // of them is read the reply: it takes the one field it needs and lets the rest -// go unexamined. morph#405's mutation run measured what that costs -- the -// `reply(...)` call itself could be deleted at twenty sites in `remote.hpp` +// go unexamined. A mutation run measures what that costs -- the +// `reply(...)` call itself can be deleted at twenty sites in `remote.hpp` // and `morph_tests` stayed green, because no case at those sites asserts on // the envelope the deleted call would have produced. // diff --git a/tests/test_remote_step_interleaving.cpp b/tests/test_remote_step_interleaving.cpp index 5f151c90c..05291c1eb 100644 --- a/tests/test_remote_step_interleaving.cpp +++ b/tests/test_remote_step_interleaving.cpp @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Covers the public deterministic-interleaving-harness seam for `RemoteServer` -// (issue #55, use case 2): hand-stepping `RemoteServer`'s real per-model +// hand-stepping `RemoteServer`'s real per-model // ordering via `morph::testing::StepExecutor`, without naming // `morph::exec::detail::StrandExecutor` or `morph::exec::detail::ModelId`. diff --git a/tests/test_render_locale_format.cpp b/tests/test_render_locale_format.cpp index fea5de592..210b338d7 100644 --- a/tests/test_render_locale_format.cpp +++ b/tests/test_render_locale_format.cpp @@ -151,7 +151,7 @@ TEST_CASE("render::locale_format round-trips through a multi-byte separator", "[ CHECK(normalizeLocaleNumber(display, {.decimalSeparator = ",", .groupSeparator = kNarrowNbsp}) == "1050.25"); } -// ── morph#497: a sign after the decimal separator is not "leading" ── +// ── A sign after the decimal separator is not "leading" ── // // `sawAnyOutput` was only set at the bottom of the loop, and the // decimal-separator branch `continue`d past it -- so after a separator the sign @@ -188,7 +188,7 @@ TEST_CASE("normalizeLocaleNumber: the loose shapes stay accepted, in step with t REQUIRE(morph::render::normalizeLocaleNumber(".", {.decimalSeparator = ".", .groupSeparator = ","}) == "."); } -// ── morph#574: a group separator is validated, not stripped ────────────────── +// ── A group separator is validated, not stripped ───────────────────────────── // // Before this, every occurrence of the group separator was dropped // unconditionally, so a de-DE user typing the US form "1.5" into a price field @@ -275,7 +275,7 @@ TEST_CASE("normalizeLocaleNumber: every well-formed locale entry still normalise CHECK(formatCanonicalNumber(*canonical, {.decimalSeparator = ",", .groupSeparator = "."}) == "1.000.000,25"); } -// ──── morph#583: the negative sign is locale data, and is not always one byte ──── +// ──── The negative sign is locale data, and is not always one byte ──── // // Of the 711 locales Qt 6.11.2 reports through QLocale::matchingLocales, 77 // spell the negative sign as something other than a bare ASCII '-': @@ -377,7 +377,7 @@ TEST_CASE("normalizeLocaleNumber: the ASCII hyphen stays accepted in every local TEST_CASE("normalizeLocaleNumber: a locale sign is still rejected off the leading position", "[render][locale][morph583]") { - // The morph#497 rule is about the *output*, so it has to hold for a + // The leading-position rule is about the *output*, so it has to hold for a // multi-byte sign exactly as it does for '-'. CHECK(normalizeLocaleNumber("1" + entry(kSignEuEs, "2"), {.decimalSeparator = ".", .groupSeparator = "", .negativeSign = kSignEuEs}) == @@ -424,7 +424,7 @@ TEST_CASE("locale_format: an empty negative sign reads as '-', not as 'no sign'" // Unlike a group separator, there is no locale without a negative sign, so // empty cannot mean absence. On the display edge it would be a silently // wrong value: -5 formatted to "5" is a valid number of the wrong sign, - // which is the morph#574 failure mode, not a rejection. + // which is silent corruption, not a rejection. CHECK(formatCanonicalNumber("-5", {.decimalSeparator = ".", .groupSeparator = "", .negativeSign = ""}) == "-5"); CHECK(normalizeLocaleNumber("-5", {.decimalSeparator = ".", .groupSeparator = "", .negativeSign = ""}) == "-5"); // And an empty needle must not match at every index: a scan that treated it @@ -432,7 +432,7 @@ TEST_CASE("locale_format: an empty negative sign reads as '-', not as 'no sign'" CHECK(normalizeLocaleNumber("123", {.decimalSeparator = ".", .groupSeparator = "", .negativeSign = ""}) == "123"); } -// ──── morph#596: a leading positive sign is accepted, and dropped ──────────── +// ──── A leading positive sign is accepted, and dropped ─────────────────────── // // `normalizeLocaleNumber` had no notion of a positive sign at all: a leading // '+' fell through to the "any other character is malformed" arm, so an @@ -516,7 +516,8 @@ TEST_CASE("normalizeLocaleNumber: a locale's multi-code-point positive sign is m } TEST_CASE("normalizeLocaleNumber: the ASCII '+' stays accepted in a bidi-sign locale", "[render][locale][morph596]") { - // The morph#583 precedent: the locale's own spelling is on no keyboard, so + // The same rule the negative sign follows: the locale's own spelling is on + // no keyboard, so // matching only it would reject the sign the user can actually type. CHECK(normalizeLocaleNumber( "+5", {.decimalSeparator = ".", .groupSeparator = "", .negativeSign = "-", .positiveSign = kPlusArEg}) == @@ -536,8 +537,8 @@ TEST_CASE("normalizeLocaleNumber: the ASCII '+' stays accepted in a bidi-sign lo TEST_CASE("normalizeLocaleNumber: a positive sign obeys the same leading-position rule", "[render][locale][morph596]") { - // morph#497's rule is about the *output*, so accepting a new sign spelling - // must not open a new way to inject one. + // The leading-position rule is about the *output*, so accepting a new sign + // spelling must not open a new way to inject one. CHECK(normalizeLocaleNumber("1+2", {.decimalSeparator = ".", .groupSeparator = ""}) == std::nullopt); CHECK(normalizeLocaleNumber("+-5", {.decimalSeparator = ".", .groupSeparator = ""}) == std::nullopt); CHECK(normalizeLocaleNumber("-+5", {.decimalSeparator = ".", .groupSeparator = ""}) == std::nullopt); @@ -593,20 +594,20 @@ TEST_CASE("normalizeLocaleNumber: the new parameter costs no existing behaviour" CHECK(normalizeLocaleNumber("1.050,25", {.decimalSeparator = ",", .groupSeparator = "."}) == "1050.25"); CHECK(normalizeLocaleNumber("-1.050,25", {.decimalSeparator = ",", .groupSeparator = "."}) == "-1050.25"); CHECK(normalizeLocaleNumber("1.5", {.decimalSeparator = ",", .groupSeparator = "."}) == - std::nullopt); // morph#574 still holds + std::nullopt); // grouping validation still holds CHECK(normalizeLocaleNumber("abc", {.decimalSeparator = ".", .groupSeparator = ""}) == std::nullopt); CHECK(normalizeLocaleNumber("", {.decimalSeparator = ".", .groupSeparator = ""}) == std::nullopt); CHECK(normalizeLocaleNumber(entry(kSignEuEs, "5"), {.decimalSeparator = ".", .groupSeparator = "", .negativeSign = kSignEuEs}) == - "-5"); // morph#583 still holds + "-5"); // the whole-string sign match still holds } -// ──── morph#599: the QML mirror's separators, cross-checked here ───────────── +// ──── The QML mirror's separators, cross-checked here ──────────────────────── // // This block adds no C++ behaviour. `normalizeLocaleNumber` has matched both // separators as whole strings since it was written -- "accepts a multi-byte -// group separator" above already pins that -- and morph#599 is a defect in the -// *QML mirror* (`src/qt/forms/qml/DynamicForm.qml`), which compared one UTF-16 +// group separator" above already pins that. The defect this guards against is +// in the *QML mirror* (`src/qt/forms/qml/DynamicForm.qml`), which can compare one UTF-16 // code unit (`ch === groupSeparator`) while this side used // `rest.starts_with`. docs/spec/forms/forms.md, "Both edges, or neither": a // divergence between the two is a divergence in what the product accepts, so @@ -695,7 +696,7 @@ TEST_CASE("locale_format: the multi-unit separator corpus the QML mirror now sha TEST_CASE("locale_format: a multi-unit separator is validated exactly as a one-unit one is", "[render][locale][morph599]") { - // morph#574's grouping validation and morph#497's leading-position rule are + // The grouping validation and the leading-position rule are // stated over "the separator", so they have to hold when it is longer than // one unit -- on both edges. Same rows as the mirror's // `test_aMultiUnitSeparatorIsStillValidatedTheSameWay`. @@ -723,7 +724,7 @@ TEST_CASE("locale_format: the pair round-trips through a multi-unit separator", // Where "both edges, or neither" bites. `formatCanonicalNumber` has always // emitted the separators whole on both sides, so with a multi-unit // separator the mirror's display edge produced text its own entry edge then - // rejected -- the morph#583 shape, for a locale that does not exist yet. + // rejected -- the two-edge disagreement, for a locale that does not exist yet. // This side round-tripped throughout; that is what made the two disagree. auto const display = formatCanonicalNumber( "-1050.25", {.decimalSeparator = kDecimal2, .groupSeparator = kGroup2, .negativeSign = kSignEuEs}); @@ -733,7 +734,7 @@ TEST_CASE("locale_format: the pair round-trips through a multi-unit separator", "-1050.25"); } -// ---- morph#591: the digits are locale data too ------------------------------ +// ---- The digits are locale data too ---------------------------------------- // // Measured on this revision with QLocale::matchingLocales under Qt 6.11.2, over // all 711 locales: 76 report a zeroDigit other than ASCII '0', across eleven @@ -769,7 +770,7 @@ struct DigitSet { // One representative per distinct set, spelled as explicit UTF-8 bytes with the // code point in the comment -- the house style for this file, and the reason -// #610 converted the QML mirror to escapes: a digit that renders as itself is +// the QML mirror spells them as escapes too: a digit that renders as itself is // still unreadable when a reviewer does not read that script. constexpr std::array kDigitSets = {{ {.name = "ar_BH", .zero = "\xD9\xA0", .five = "\xD9\xA5"}, // U+0660, U+0665 @@ -796,8 +797,8 @@ TEST_CASE("formatCanonicalNumber: the display edge emits the locale's digits", " // The exact bytes QLocale("ar_BH").toString(-1050.25) produces under // Qt 6.11.2, measured rather than derived: // U+061C U+002D U+0661 U+066C U+0660 U+0665 U+0660 U+066B U+0662 U+0665 - // Before morph#591 this edge emitted "\u061c-1\u066c050\u066b25" -- the - // locale's sign and separators around ASCII digits, which is what made the + // Without a digit base this edge emits "\u061c-1\u066c050\u066b25" -- the + // locale's sign and separators around ASCII digits, which is what makes the // pair self-consistent and the defect invisible from either side alone. CHECK(formatCanonicalNumber("-1050.25", {.decimalSeparator = kArDecimal, .groupSeparator = kArGroup, @@ -842,7 +843,8 @@ TEST_CASE("locale_format: the pair round-trips through every measured digit set" } TEST_CASE("normalizeLocaleNumber: ASCII digits stay accepted in a native-digit locale", "[render][locale][morph591]") { - // The morph#596 precedent, applied to digits: the locale's own digits are + // The same rule the positive sign follows, applied to digits: the locale's + // own digits are // on the user's keyboard only if their keyboard has them. Entry therefore // accepts a spelling display never produces, exactly as it does for the // ASCII '+' and '-'. diff --git a/tests/test_replay_ledger.cpp b/tests/test_replay_ledger.cpp index 3699fc646..8f83c4a40 100644 --- a/tests/test_replay_ledger.cpp +++ b/tests/test_replay_ledger.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// morph::offline::IReplayLedger (morph#226): the op-id/exactly-once replay +// morph::offline::IReplayLedger: the op-id/exactly-once replay // ledger promoted out of seven near-identical hand-written copies across five // example rungs. See include/morph/offline/replay_ledger.hpp for the // interface's own rationale and docs/spec/offline/offline.md for the diff --git a/tests/test_reply_router.cpp b/tests/test_reply_router.cpp index 6d42aa228..8397030bb 100644 --- a/tests/test_reply_router.cpp +++ b/tests/test_reply_router.cpp @@ -63,7 +63,7 @@ TEST_CASE("classifyExecuteReply: an ok reply classifies as Value", "[backend][re TEST_CASE("classifyExecuteReply: an err reply carrying the timeout message classifies as Timeout", "[backend][reply_router][timeout]") { // The unit-level half of test_socket_backend.cpp's "executeTimeout surfaces - // as backend::TimeoutError" regression case (#447): the server's own + // as backend::TimeoutError" regression case: the server's own // LimitPolicy::executeTimeout reply must be distinguishable from an // arbitrary `err`, so callers can tell "the server gave up on this specific // call" from an application error. That case still exists over the real diff --git a/tests/test_sections.cpp b/tests/test_sections.cpp index 67d528c77..49e1dc9da 100644 --- a/tests/test_sections.cpp +++ b/tests/test_sections.cpp @@ -208,7 +208,7 @@ TEST_CASE("SectionSet: sections fire independently, in any order", "[sections]") // Edit the SECOND section first. Under FlowSession this throws // std::logic_error -- "field belongs to an action that is not the current - // step" -- which is exactly the gap morph#513 reports. + // step" -- which is exactly the gap this layer exists to close. sections.set<&SecPrefs::theme>("dark"); drain(cbExec); diff --git a/tests/test_server_limits.cpp b/tests/test_server_limits.cpp index dcaf79204..9a1c5b8c9 100644 --- a/tests/test_server_limits.cpp +++ b/tests/test_server_limits.cpp @@ -190,7 +190,7 @@ TEST_CASE("benchmark: in-process execute round-trip", "[!benchmark][remote]") { std::atomic next{0}; constexpr int n = 32; // Heap-allocated and co-owned by every reply callback, not a stack - // local captured by reference (morph#565). The wait below is bounded, + // local captured by reference. The wait below is bounded, // so the body can and does return with replies still in flight; a // stack-local counter is destroyed at that point and the straggler's // `fetch_add` writes into a dead frame. Catch2 then re-enters this body diff --git a/tests/test_shared_instances.cpp b/tests/test_shared_instances.cpp index 95ecbcd48..a90ea1081 100644 --- a/tests/test_shared_instances.cpp +++ b/tests/test_shared_instances.cpp @@ -266,7 +266,7 @@ TEST_CASE("two AllowShared handlers naming one key reach one instance", "[shared REQUIRE(second.primary().value_or(-1) == 42); } -// ── Issue #68: executeJson skips the payload-keyed attach step for +// ── executeJson skips the payload-keyed attach step for // AllowShared handlers ─────────────────────────────────────────────────── // // ActionExecuteRegistry::registerAction used to build a single executor that @@ -1334,7 +1334,7 @@ TEST_CASE("deregistering a poisoned instance evicted from the directory tears it REQUIRE(okWaiter.env.kind == "ok"); } -// ── morph#523: the directory must learn a first action's outcome before any +// ── The directory must learn a first action's outcome before any // ── host code can attach to its key ────────────────────────────────────────── // // `docs/spec/core/shared_instances.md`'s Failure modes section: an instance @@ -1480,7 +1480,7 @@ TEST_CASE("the server does not hand out an instance between its first action fai REQUIRE(attached.load() != reg.modelId); } -// morph#523: an instance that was *created* for a directory key can never be +// An instance that was *created* for a directory key can never be // promoted onto a second one, and "created for a key" outlives being filed // under it. `InstanceDirectory::attach` clears the `sharedKey` of an instance it // evicts as poisoned, so from the directory's side an evicted instance looks diff --git a/tests/test_strand.cpp b/tests/test_strand.cpp index f11ec92a2..b2f0cbd38 100644 --- a/tests/test_strand.cpp +++ b/tests/test_strand.cpp @@ -19,7 +19,7 @@ TEST_CASE("morph::exec::detail::StrandExecutor serialises tasks for the same key // Scoped so ~StrandExecutor's own _inFlight == 0 wait (strand.hpp) is the // drain, not a fixed-iteration poll: every queued task has run by the time // this block exits, with no dependence on how fast the host runs them - // (morph#396 -- the shape morph#374 fixed here first). + // rather than on a sleep long enough to have probably finished. { morph::exec::detail::StrandExecutor strand{pool}; for (int i = 0; i < numTasks; ++i) { diff --git a/tests/test_strand_race.cpp b/tests/test_strand_race.cpp index 2a586957d..6e9489c35 100644 --- a/tests/test_strand_race.cpp +++ b/tests/test_strand_race.cpp @@ -40,20 +40,20 @@ // assumed -- with the pre-fix two-step drain restored in `scheduleNext` (flip // `running` under `strand->mtx`, release it, then erase under `_mapMtx` in a // separate critical section), this case passed 10/10 under ThreadSanitizer on -// x86-64 Linux / clang 22.1.8 (morph#668). Short tasks maximise *re-arm*; they do +// x86-64 Linux / clang 22.1.8. Short tasks maximise *re-arm*; they do // not produce a drain. Turning the thread or post counts up makes that worse, // not better. // // The second case below produces the shape this one cannot, and is the one that // fails against that mutant. The third covers the node the drain now recycles -// (morph#670), which neither of the first two can be wrong about. Keep all +// which neither of the first two can be wrong about. Keep all // three: saturation, the drain boundary, and the recycled node's key are // different failure modes of the same invariant. namespace { // ── The drain's diagnostic, and why it is a watchdog and not a deadline ────── // -// morph#717 observed this case hang under ThreadSanitizer and be killed by +// This case has been observed hanging under ThreadSanitizer and killed by // ctest's 120 s TIMEOUT having printed nothing but the Catch2 banner: no // assertion, no TSan report, no reason. That observation is weak and stays // weak -- 1 of 3 full-suite runs, 0 of 40 isolated, and this lane did not @@ -62,8 +62,8 @@ namespace { // The *structural* half of the issue is checkable by reading, and it holds. // `~StrandExecutor` waits on // `_cv.wait(lock, [this] { return _inFlight == 0; })` -// (`include/morph/core/strand.hpp:64-66`) with no timeout, and morph#374 -// scoped the strand so that this wait *is* the drain. +// (`include/morph/core/strand.hpp:64-66`) with no timeout, and the strand is +// scoped so that this wait *is* the drain. // // One correction to the issue's framing, because it decides the remedy. The // pre-#374 `2000 x 1 ms` budget was never a bound on the hang: @@ -71,11 +71,11 @@ namespace { // iteration regardless, so a lost wakeup hung the pre-#374 binary just as // thoroughly. What that budget bounded was the time to the *first diagnostic* // -- a failed `REQUIRE` naming `completed` against `kExpected`, printed before -// the same unbounded wait was entered. morph#374 did not create the hang. It -// removed the only thing that spoke before it. +// the same unbounded wait was entered. A deadline there does not create the +// hang; it is the only thing that speaks before it. // // So restoring a deadline would be the wrong remedy twice over: it would not -// bound the hang, and it would re-introduce precisely what morph#374 fixed -- +// bound the hang, and it would re-introduce // a `REQUIRE` about how fast the host is, evaluated before the invariant this // file exists for. What is restored below is the diagnostic with no verdict // attached: a watchdog thread that says where the case is and whether it is @@ -84,8 +84,8 @@ namespace { // and the ctest timeout that follows carries the evidence it used to lack. // // The `+N since the last report` field is the whole point: it separates "this -// host is slow" from "this strand is stuck", which is the one thing morph#717 -// could not determine about its own observation. +// host is slow" from "this strand is stuck", which is the one thing a bare +// timeout cannot determine about an observed hang. // // The period is `MORPH_STRAND_DRAIN_WATCHDOG_MS`, default 10000. Ten seconds // against a 0.4 s median for the whole case leaves about eleven reports inside @@ -149,11 +149,10 @@ class DrainWatchdog { // second thread, where Catch2's macros are not safe to call, and // the point is to emit something even when the process is about to // be killed. Flushed per line so a SIGKILL cannot eat half a report. - std::cerr << "morph#717 strand-race watchdog: iteration " << _state->iteration << ", phase '" - << _state->phase.load() << "', " << elapsed << " s into the iteration, completed " << current - << "/" << _state->expected << " (+" << (current - previous) - << " since the last report), inFlight " << _state->inFlight->load() << ", maxInFlight " - << _state->maxInFlight->load() << '\n' + std::cerr << "strand-race watchdog: iteration " << _state->iteration << ", phase '" << _state->phase.load() + << "', " << elapsed << " s into the iteration, completed " << current << "/" << _state->expected + << " (+" << (current - previous) << " since the last report), inFlight " + << _state->inFlight->load() << ", maxInFlight " << _state->maxInFlight->load() << '\n' << std::flush; previous = current; } @@ -168,7 +167,7 @@ class DrainWatchdog { } // namespace -// `[slow]` (morph#760) is what gives this case its own ctest `TIMEOUT`; see +// `[slow]` is what gives this case its own ctest `TIMEOUT`; see // tests/CMakeLists.txt, where the tag is excluded from the blanket 120 s and // registered again with a budget sized from this case's measured loaded // runtime. It is a *scheduling* budget, not a performance one -- see the note @@ -180,7 +179,7 @@ TEST_CASE("StrandExecutor never runs two tasks for one key concurrently under co // pool/strand pair sampling the drain-and-re-arm interleaving once; twenty // of them is how often this case gets to observe it. Cutting this number // is the cheap way to fit a timeout and it makes the case worse at the one - // thing it exists for, so the budget was moved instead (morph#760). + // thing it exists for, so the budget lives on the ctest entry instead. // // What the case actually costs is set by the *scheduler*, not by the work: // the strand serialises `kThreads * kPostsPerThread` tasks, and each @@ -264,8 +263,8 @@ TEST_CASE("StrandExecutor never runs two tasks for one key concurrently under co // dips to zero across a handoff, so `_inFlight == 0` with no // producer left means every queued task has run. // - // This replaces a fixed budget of 2000 x 1 ms sleeps (morph#374). - // That budget was ~2 s of wall clock for 3200 strand-serialised + // Not a fixed budget of 2000 x 1 ms sleeps. + // That budget is ~2 s of wall clock for 3200 strand-serialised // tasks, 20 times over, and could expire with work still queued on // a loaded machine. Worse, the deficit was a `REQUIRE` and came // first, so Catch2 aborted the case before `maxInFlight` -- the @@ -301,7 +300,7 @@ TEST_CASE("StrandExecutor never runs two tasks for one key concurrently under co } } -// The drain-and-re-arm boundary (morph#668), which the case above never reaches. +// The drain-and-re-arm boundary, which the case above never reaches. // // Shape, not volume. The defect needs a strand to reach *empty* while a post is // arriving, so this case manufactures that rendezvous instead of hoping for it: @@ -464,7 +463,7 @@ TEST_CASE("StrandExecutor keeps one strand per key when a post races the drain", } } -// The recycled map node (morph#670), which neither case above can be wrong +// The recycled map node, which neither case above can be wrong // about. // // When a strand drains, `scheduleNext` no longer `erase`s the map entry: it @@ -605,7 +604,7 @@ TEST_CASE("ThreadPoolExecutor(0) yields a usable pool", "[executor][race]") { // Scoped so ~ThreadPoolExecutor's own drain-before-join (executor.hpp's own // doc comment on it) is the wait, not a fixed-iteration poll: the posted // task is queued before this block ends, so the destructor's join is - // guaranteed not to return until it has run (morph#396). + // guaranteed not to return until it has run. { morph::exec::ThreadPoolExecutor pool{0}; pool.post([&] { ran.store(true); }); diff --git a/tests/test_strong_id_keys.cpp b/tests/test_strong_id_keys.cpp index b72796018..ef312cc08 100644 --- a/tests/test_strong_id_keys.cpp +++ b/tests/test_strong_id_keys.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// Tests for keying a model on a *strong id* (morph#163). +// Tests for keying a model on a *strong id*. // // `examples/IMPLEMENTATION.md` rule 3 requires entity identity to be a // per-entity strong id type exposing `hasValue()`. Before this, such a type @@ -157,7 +157,8 @@ BRIDGE_REGISTER_MODEL(SikRowModel, "SIK_RowModel") BRIDGE_REGISTER_ACTION(SikRowModel, SikOpenRow, "SIK_OpenRow") BRIDGE_REGISTER_ACTION(SikRowModel, SikBump, "SIK_Bump") -// The line this issue is about: before morph#163 this did not compile, because +// The load-bearing line: with `ModelKey` admitting raw scalars only, this does +// not compile, because // `SikRowId` satisfied neither arm of `ModelKey`. BRIDGE_MODEL_KEY(SikRowModel, SikOpenRow, &SikOpenRow::id); diff --git a/tests/test_support.hpp b/tests/test_support.hpp index a197882af..5dfc8bd06 100644 --- a/tests/test_support.hpp +++ b/tests/test_support.hpp @@ -37,7 +37,7 @@ struct InlineExecutor : ::morph::exec::IExecutor { /// @brief `IExecutor` that queues every posted task and runs them only when the /// test explicitly asks, one at a time. /// -/// The public interleaving-test harness for issue #55's use case 2: any server +/// The public interleaving-test harness: any server /// component built on `morph::exec::IExecutor` — `RemoteServer` included — can /// be driven with fully deterministic, hand-stepped task ordering by /// constructing it against a `StepExecutor` instead of a `ThreadPoolExecutor`. @@ -206,7 +206,7 @@ class DeterministicExecutor : public ::morph::exec::IExecutor { std::deque> _queue; }; -// ── The wait primitives are for liveness. Never time across one (morph#708) ── +// ── The wait primitives are for liveness. Never time across one ───────────── // // `waitUntil` below, and `WaitReply::await` further down, answer *"did this // eventually happen?"*. They do not answer *"how long did this take?"*, and @@ -220,11 +220,11 @@ class DeterministicExecutor : public ::morph::exec::IExecutor { // `WaitReply::await()` around each of 2000 serial round trips and published a // p50 of 5074 us, while the same processes reported ~176k executes/sec at // concurrency 1 — a round trip of about 5.7 us. Three orders of magnitude, on -// a figure that had a CI gate on it. morph#710 fixes that file by replacing -// the waiter with a condition variable (`BlockingReply` there); copy that +// a figure that had a CI gate on it. The fix there is to replace +// the waiter with a condition variable (`BlockingReply`); copy that // shape if you need to time something. // -// ── Audit, master @ 6f95f49a (morph#708) ───────────────────────────────────── +// ── Audit of the call sites ───────────────────────────────────────────────── // // 433 poll sites were classified: 211 direct `waitUntil(` invocations across 43 // files, plus 222 `await()` invocations, which reach the same loop through @@ -250,7 +250,7 @@ class DeterministicExecutor : public ::morph::exec::IExecutor { // // ── What the step costs a suite run, measured ──────────────────────────────── // -// morph#708 listed this as unmeasured. It is not free, and the bill reads the +// It is not free, and the bill reads the // same two independent ways. Measured on an otherwise-quiet 12-core Linux box // (clang 22.1.8, Release, load average 0.9-2.1), one binary instrumented to // take the step from the environment so that the two arms differ in nothing @@ -283,7 +283,7 @@ inline constexpr std::chrono::milliseconds kDefaultWaitBudget{2000}; /// @brief Default polling step for `waitUntil`. inline constexpr std::chrono::milliseconds kDefaultWaitStep{5}; -// ── Why these are two types and not two `milliseconds` (morph#721) ─────────── +// ── Why these are two types and not two `milliseconds` ───────────────────── // // `waitUntil` used to take `(Pred, milliseconds budget = 2000ms, // milliseconds step = 5ms)`: two adjacent, same-type, both-defaulted @@ -300,10 +300,10 @@ inline constexpr std::chrono::milliseconds kDefaultWaitStep{5}; // hazard is ever reintroduced. // // Two escapes were available and both were rejected. A `NOLINT` would have -// removed the *warning* and left the hazard (morph#404). Reordering the +// removed the *warning* and left the hazard. Reordering the // parameters so they are no longer adjacent would have removed the -// heuristic's view of them and left the hazard too -- morph#715 measured that -// exact outcome on `annotateExactBound`, where widening a parameter to +// heuristic's view of them and left the hazard too -- that exact outcome was +// measured on `annotateExactBound`, where widening a parameter to // `std::string_view` silenced `bugprone-easily-swappable-parameters` while the // transposition still compiled. // @@ -384,7 +384,7 @@ concept WaitUntilCallableWith = requires(Args... args) { waitUntil(args...); }; /// @brief A stand-in predicate type for the assertions below. using ExampleWaitPred = bool (*)(); -// The acceptance test for morph#721, and the reason this header is the right +// The acceptance test for that separation, and the reason this header is the right // place for it: these run in every translation unit that includes it, so the // hazard cannot be reintroduced by a later edit without reddening the build. // diff --git a/tests/test_switch_backend.cpp b/tests/test_switch_backend.cpp index 4e65f6117..af0334973 100644 --- a/tests/test_switch_backend.cpp +++ b/tests/test_switch_backend.cpp @@ -842,7 +842,7 @@ TEST_CASE( (void)midB; } -// ── The two re-registration sites and `bindWaitPolicy` (morph#615) ─────────── +// ── The two re-registration sites and `bindWaitPolicy` ───────────────────── // // `switchBackend`'s phase 1 and the reconnect handler both called the blocking // `registerModelShared`/`registerModelWithContext` directly, with no policy @@ -945,7 +945,7 @@ class DeferredBindBackend : public morph::backend::LocalBackend { // "did the site come back within the polling budget", so a parked legacy // verb must still be parked when that budget runs out. A bound near the // budget would make the measurement a coin flip -- observed, while - // mutation-testing morph#615: with both set to two seconds the mutated + // mutation-testing this pair: with both set to two seconds the mutated // (blocking) build passed. void hold() { std::unique_lock lock{_gateMtx}; diff --git a/tests/test_sync_worker.cpp b/tests/test_sync_worker.cpp index 147a9d530..fd0782b7e 100644 --- a/tests/test_sync_worker.cpp +++ b/tests/test_sync_worker.cpp @@ -386,7 +386,7 @@ TEST_CASE("morph::offline::SyncWorker: run() over a queue at maxDepth still drai REQUIRE_NOTHROW(queue.enqueue("e")); } -// ── Issue #343: an undelivered replay must not spend the retry budget ─────── +// ── An undelivered replay must not spend the retry budget ────────────────── // // `ReplayFunction` returns `bool`, whose only two outcomes are "remove it" and // "charge one attempt". That gives the caller no way to say *"this never diff --git a/tests/test_timeout_scheduler.cpp b/tests/test_timeout_scheduler.cpp index 37551421b..794d2e080 100644 --- a/tests/test_timeout_scheduler.cpp +++ b/tests/test_timeout_scheduler.cpp @@ -101,7 +101,7 @@ TEST_CASE("TimeoutScheduler: cancel() before the deadline prevents the callback // ── What `cancel()` does about a callback that has already started ─────────── // -// The header now states the distinction these two cases make (issue #620): +// The header states the distinction these two cases make: // `cancel()` stops a callback that has not started, and returns *without // waiting* for one that has. Only `~TimeoutScheduler` means "no callback is in // flight", because only it joins. Both halves are asserted below so the prose diff --git a/tests/test_widget_hints.cpp b/tests/test_widget_hints.cpp index b01d09581..b0318873e 100644 --- a/tests/test_widget_hints.cpp +++ b/tests/test_widget_hints.cpp @@ -28,7 +28,7 @@ static_assert(morph::forms::Multiline::widget() == "textarea"); // The `$defs` key splits exactly where the generated definition differs. // `char` earns its own tag because glaze writes it as a JSON *string* while // the equally-wide, equally-signed `std::int8_t` is an integer -- sharing one -// entry would describe only one of them, which is morph#543. +// entry would describe only one of them. static_assert(morph::forms::detail::rangedSchemaName != morph::forms::detail::rangedSchemaName); static_assert(morph::forms::detail::rangedSchemaName == "Ranged_i32"); static_assert(morph::forms::detail::rangedSchemaName == "Ranged_f64"); @@ -175,7 +175,7 @@ struct WHRealFieldMetaAction { }; // Two `Ranged` fields whose payload types differ. Both `$ref`ed the one -// `$defs/Ranged` entry before morph#543, so whichever glaze populated first +// `$defs/Ranged` entry under a fixed name, so whichever glaze populated first // described the other one too -- an int slider served as a double, or a // double slider served as an int whose every legal value fails the type it // was handed under. @@ -276,7 +276,7 @@ TEST_CASE("Forms::SchemaJson::PlainFieldsEmitNoWidgetHint", "[forms][widget-hint // form also matched any *longer* key sharing the prefix, so an unrelated // key named `x-min...` failed this test with a message pointing at the // slider bounds — which is how `x-exactMinimum` was first named, before - // this caught it (morph#213). + // this caught it. auto const schema = morph::forms::schemaJson(); CHECK_FALSE(schema.contains(R"("x-widget":)")); CHECK_FALSE(schema.contains(R"("x-min":)")); diff --git a/tests/test_wire_hardening.cpp b/tests/test_wire_hardening.cpp index 12e49b894..ebf7d0740 100644 --- a/tests/test_wire_hardening.cpp +++ b/tests/test_wire_hardening.cpp @@ -93,7 +93,7 @@ TEST_CASE("decode accepts an envelope at the size limit boundary", "[wire][harde // stays either comfortably under the cap (1 KiB) or comfortably over it // (+1024 bytes), which leaves the guard's own boundary untouched: mutating // `>` to `>=` -- moving the cap down by one byte and rejecting a legal -// envelope -- passed the entire suite (morph#405, the first mutation run over +// envelope -- passes the entire suite (measured by a mutation run over // include/morph/core). A cap is one comparison, and a comparison is only // tested at the value where changing it changes the answer. // diff --git a/tests/test_wire_omitted_fields.cpp b/tests/test_wire_omitted_fields.cpp index ca244cb19..61a0936d3 100644 --- a/tests/test_wire_omitted_fields.cpp +++ b/tests/test_wire_omitted_fields.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// `encode` leaves out the envelope fields that hold their default (morph#524). +// `encode` leaves out the envelope fields that hold their default. // // `wire::Envelope` is a union-of-all-kinds struct: an `ok` reply uses three of // its thirteen members, but glaze writes every member of a struct it is handed, @@ -100,8 +100,8 @@ TEST_CASE("morph::wire: every omittable field survives when it is not at its def TEST_CASE("morph::wire: the legacy all-keys form decodes to the same envelope as the short form", "[wire][omitted-fields]") { - // The exact bytes `encode` produced for this envelope before morph#524 — - // captured from the pre-change build, not regenerated, so this case still + // The exact bytes a defaults-writing `encode` produces for this envelope — + // captured from such a build, not regenerated, so this case still // means something if `encode` changes again. static constexpr std::string_view kLegacyOk = R"({"kind":"ok","callId":7,"typeId":"","contextKey":"","primary":"","shared":false,"modelId":0,)" diff --git a/tests/test_wire_schemas.cpp b/tests/test_wire_schemas.cpp index 641debe5d..515438107 100644 --- a/tests/test_wire_schemas.cpp +++ b/tests/test_wire_schemas.cpp @@ -1,13 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 -// The `"schemas"` envelope kind (morph#234) and the action-evolution policy -// gate it feeds (morph#207). +// The `"schemas"` envelope kind and the action-evolution policy gate it feeds. // -// morph#234: a client that is not linked against a model's C++ had no way to -// ask what an action's inputs are -- `morph::forms::schemaJson()` is a +// Without the kind, a client that is not linked against a model's C++ has no +// way to ask what an action's inputs are -- `morph::forms::schemaJson()` is a // compile-time function and no envelope kind served its output. // -// morph#207: the action codec's lenient decode matches fields by *name* and +// And the action codec's lenient decode matches fields by *name* and // default-constructs an absent one, so a client/server field rename // (`amountCents` -> `amount`) decodes to a zero-valued action that // `validate()` cannot distinguish from a legitimate zero. The two belong @@ -47,7 +46,7 @@ using morph::wire::makeSchemas; // Model/action types need external linkage for glaze reflection (see // tests/test_policy_hardening.cpp). -/// The action morph#207 was reported against, in miniature: one mandatory +/// The shape that defect takes, in miniature: one mandatory /// field carrying the whole meaning of the request, and one field the author /// declared optional. struct WireSchemasDeposit { @@ -370,7 +369,7 @@ TEST_CASE("RemoteServer defaults to PayloadCompleteness::Lenient", "[remote][com TEST_CASE("REGRESSION GUARD: leniently, a renamed field is accepted and applies nothing", "[remote][completeness][issue207]") { - // morph#207's measured defect, pinned as it stands today so the default + // The measured defect, pinned as it stands so the default // path cannot change without this failing. The client sends `amount`; the // server's action declares `amountCents`. The unknown key is dropped, the // absent one is default-constructed, and the deposit silently applies 0. @@ -425,7 +424,7 @@ TEST_CASE("POLICY: RequireDeclaredFields still accepts a newer client's additive // "Additive-only within a major version" is the first bullet of the very // policy this gate enforces, so the gate must not be a strict decode: // `error_on_unknown_keys = true` turns this legal payload into a parse - // error, which is why morph#207 rules it out as a partial measure. + // error, which is why it is not the answer. morph::exec::ThreadPoolExecutor pool{2}; auto server = std::make_shared(pool); server->setPayloadCompleteness(PayloadCompleteness::RequireDeclaredFields); From db4e47c7d05fde60935509841a28252d2f48223d Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 23 Sep 2026 22:42:44 +0200 Subject: [PATCH 6/6] coverage: repoint seven allowlist line hints the comment cleanup moved Removing comment lines from include/morph shifted the branches the partial- coverage allowlist pins, and the gate resolves each entry by line first. Seven of twenty-one entries no longer landed on their recorded source text, so the coverage leg failed on master and on every branch cut from it -- including two pull requests that change nothing near these files. core/backend.hpp 1302 -> 1294 core/remote.hpp 1416 -> 1413 core/remote.hpp 1494 -> 1491 core/bridge.hpp 2041 -> 2012 net/socket_backend.hpp 146 -> 145 net/socket_backend.hpp 158 -> 157 net/socket_backend.hpp 826 -> 823 Only the hints move. Every entry still resolves to the identical source text it recorded, each match is unique in its file, and no disposition, reason or measurement is touched -- the gate's complaint was about where to look, not about what it found there. Worth noting for whoever next edits comments in a header the allowlist pins: this is a line-numbered index into files nothing stops anyone reformatting, and it drifts silently until a coverage run resolves it. The text match is what makes the repair mechanical rather than a re-audit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW --- scripts/branch_partial_allowlist.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/branch_partial_allowlist.json b/scripts/branch_partial_allowlist.json index 0f8ef5aee..d54bfc85f 100644 --- a/scripts/branch_partial_allowlist.json +++ b/scripts/branch_partial_allowlist.json @@ -103,7 +103,7 @@ }, { "file": "include/morph/core/backend.hpp", - "line": 1302, + "line": 1294, "source": "if (const auto* inst = _instances.find(modelId)) {", "reason": "Unreachable by construction given the `_changeAware`/`_instances` invariant (core audit finding BK2). `_changeAware` is an index over the instance directory: an id enters it in `createHolder` (this file, when the holder answers `isBackendChangeAware()`) in the same `_regMtx`-held critical section that files the instance, and leaves it in `deregisterModel` only when `InstanceDirectory::release` reports the instance actually destroyed. `notifyBackendChanged()` (this function) holds the same `_regMtx` while walking `_changeAware` and looking each id up at this line, so every id it walks is still live -- the null arm cannot occur without a code change that breaks that subset invariant." }, @@ -115,19 +115,19 @@ }, { "file": "include/morph/core/remote.hpp", - "line": 1416, + "line": 1413, "source": "if (_inFlightExecutes.compare_exchange_weak(current, current + 1, std::memory_order_relaxed)) {", "reason": "Real but requires genuine thread contention to trigger -- accepted as documented rather than closed with a flaky test (core audit finding RM11). The false arm (the CAS lost the race and must retry) needs two threads to genuinely collide on the same atomic increment at the same instant; it is a real, reachable hazard the retry loop correctly handles, not dead code, but inherently non-deterministic to trigger from a test without exact thread-timing control. Same disposition class as `strand.hpp`'s ST1 above, and as core audit finding O1 (`observability.hpp`'s `endSpan`), whose entry left this file once a coverage run showed its arm taken: a stress test with many concurrent `execute()` calls against a tight `maxInFlightExecutes` limit would probably eventually hit it, but flakily. RE-READ under -fprofile-update=atomic, without which this disposition is not safe to trust: the corruption direction is untaken -> appears taken, so an entry arguing \"real but never observed\" is exactly the kind that can rest on a wrapped count. It does not, and the reading below is a spread rather than a single figure -- one run cannot tell a flag effect from run order. Five CI-equivalent coverage runs, same binaries, same 2962 tests, atomic counters, on this line: `Branch (1416:21): [True: 3, False: 0]` in all five, identical to the digit. The false arm is taken by nothing in any run, so the disposition stands unchanged; had it read a wrapped 18.4E the entry would have been retired instead. What would retire it now: any run reporting a non-zero False here, which would mean the retry arm is reachable from the suite after all." }, { "file": "include/morph/core/bridge.hpp", - "line": 2041, + "line": 2012, "source": "if (_executeDeadline.count() > 0 && _timeoutScheduler) {", "reason": "Unreachable by construction (core audit finding B6). `setExecuteDeadline` (this file) is the only writer of both `_executeDeadline` and `_timeoutScheduler`, and always creates `_timeoutScheduler` in the same call that sets `_executeDeadline` positive (`_executeDeadline = deadline; if (_executeDeadline.count() > 0 && !_timeoutScheduler) { _timeoutScheduler = std::make_shared<...>(); }`, both under `_executeDeadlineMtx`); nothing anywhere resets `_timeoutScheduler` back to null -- the class's own doc comment on `setExecuteDeadline` says so explicitly (\"setting the deadline back to 0 stops new calls from arming it but does not tear the thread down\"). So `_executeDeadline > 0 && !_timeoutScheduler` cannot happen at this line once any positive deadline has ever been set." }, { "file": "include/morph/core/remote.hpp", - "line": 1494, + "line": 1491, "source": "if (_timeoutScheduler) {", "reason": "Unreachable by construction, mirrors `bridge.hpp`'s B6 (core audit finding RM10). `setLimitPolicy` (this file) is the only writer of both `_limits.executeTimeout` and `_timeoutScheduler`: `_limits = policy; if (_limits.executeTimeout.count() > 0 && !_timeoutScheduler) { _timeoutScheduler = std::make_unique<...>(); }`, both under `_limitsMtx` -- the same lock this line's enclosing block holds. Nothing anywhere nulls `_timeoutScheduler` afterward, so `dispatchExecute`'s `limits.executeTimeout.count() > 0` guard (this line's enclosing `if`, a few lines above) already guarantees `_timeoutScheduler` is non-null whenever this line runs." }, @@ -151,19 +151,19 @@ }, { "file": "include/morph/net/socket_backend.hpp", - "line": 146, + "line": 145, "source": "if (_ioThread.joinable()) {", "reason": "Unreachable by construction (net audit, `socket_backend.hpp` extra finding #5). `_ioThread` is started unconditionally in the constructor and has exactly one join site: this line, in the destructor. Nothing else in the file resets, joins, or detaches it, so at destructor time it is always joinable." }, { "file": "include/morph/net/socket_backend.hpp", - "line": 158, + "line": 157, "source": "if (_handlerThread.joinable()) {", "reason": "Unreachable by construction, same shape as `_ioThread`'s entry above (net audit, `socket_backend.hpp` extra finding #6). `_handlerThread` is started unconditionally in the constructor and has exactly one join site: this line, in the destructor. Nothing else resets, joins, or detaches it, so at destructor time it is always joinable." }, { "file": "include/morph/net/socket_backend.hpp", - "line": 826, + "line": 823, "source": "default:", "reason": "Unreachable except via the adjacent `Error` case it deliberately shares a body with (net audit, `socket_backend.hpp` extra finding #7). `detail::ExecuteReplyKind` is a closed 3-value enum (`Value`/`Timeout`/`Error`), all three handled explicitly above this label; `default:` exists only to satisfy this project's `-Wswitch-default`, per the source's own inline comment directly below this line. Reaching it via any value other than through the `Error` case falling through would require an out-of-range `static_cast` producing a value outside the enum's domain -- undefined behavior, not a legitimate test target." }