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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/spec/core/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -846,8 +846,8 @@ nothing for a caller that never uses it.

### Graceful shutdown (`beginShutdown()` / `drainedWithin()`)

`beginShutdown()` enters shutdown: every subsequent `register` and `execute`
envelope is rejected with `err "server shutting down"` (checked once, at the
`beginShutdown()` enters shutdown: every subsequent `register`, `attach` and
`execute` envelope is rejected with `err "server shutting down"` (checked once, at the
top of `dispatchMessage`, before any other validation — including the
shutdown check happening before authorization or registry lookups run);
`deregister` (and any other envelope kind) is still served so clients can
Expand Down Expand Up @@ -1661,7 +1661,7 @@ inside the class calls `close()` — no thread it joins can be waiting on it.
| `setSupportedVersionRange(min, max)` | Sets the inclusive protocol-version range advertised on `hello`. Defaults to `{kProtocolVersion, kProtocolVersion}`. Throws `std::invalid_argument` if `min > max`. Thread-safe. |
| `health()` | `[[nodiscard]] HealthStatus health() const` — snapshot of readiness/liveModels/inFlight. Cheap; safe from any thread. See [observability.md](observability.md). |
| `setHealthHandler(handler)` | `void setHealthHandler(std::function<void(const HealthStatus&)>)` — fires immediately with the current status, and again whenever readiness changes (currently only `beginShutdown()` triggers a change); `nullptr` clears without firing. |
| `beginShutdown()` | Enters shutdown: subsequent `register`/`execute` envelopes get `err "server shutting down"`; `deregister` still served. Idempotent, irreversible. Flips `health().ready` to `false` and re-invokes any installed health handler. |
| `beginShutdown()` | Enters shutdown: subsequent `register`/`attach`/`execute` envelopes get `err "server shutting down"`; `deregister` still served. A client therefore cannot re-attach to a shared instance during the drain window. Idempotent, irreversible. Flips `health().ready` to `false` and re-invokes any installed health handler. |
| `drainedWithin(deadline)` | `[[nodiscard]] bool drainedWithin(std::chrono::milliseconds deadline)` — blocks (condition-variable wait, not a poll) until every in-flight `execute` has replied or `deadline` elapses. Returns `true`/`false` accordingly. |

### `SimulatedRemoteBackend`
Expand Down
9 changes: 5 additions & 4 deletions docs/spec/core/bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,11 @@ the `ModelId` value the active backend assigned; 0 = unbound.

The last three fields are the state behind
[`isBound()` / `whenBound()`](#registration-readiness--isbound--whenbound).
`registrationInFlight` is `true` from the moment `registerHandlerImpl` hands
the binding's initial registration to `IBackend::registerModelAsync` (and that
call returns `true`) until the resulting `onRegistered`/`onError` callback
resolves; `registrationWaiters` holds the callbacks queued while it is.
`registrationInFlight` is `true` from just *before* `registerHandlerImpl` calls
`IBackend::registerModelAsync` until the resulting `onRegistered`/`onError`
callback resolves. It is set unconditionally on every path, the synchronous
fallback included — that fallback does not *leave* it set, because it resolves
the waiters and clears the flag before returning; `registrationWaiters` holds the callbacks queued while it is.
Both are guarded by `registrationMtx` — deliberately a mutex of the binding's
own, not `Bridge::_mtx` or `_attachMtx`, because a waiter may be queued or
resolved from either the registering thread or the backend's reply-delivering
Expand Down
21 changes: 9 additions & 12 deletions docs/spec/core/shared_instances.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,18 +422,15 @@ dispatch's completion instead of issuing its own; tracked as a follow-up.
Until then, a caller should not fire the same keyed action twice back-to-back
before the first settles.

**Not covered: the result-keyed *promote* step is still synchronous.** This
section made the **bind** half of a result-keyed action async
(`ensureBoundAsync` → `registerModelSharedAsync`). The **promote** half did
not change: `Bridge::assignHandlerPrimary` still calls the synchronous
`IBackend::assignPrimary`, which on `QtWebSocketBackend` is a `sendSync` —
a nested `QEventLoop`. There is no `assignPrimaryAsync`. So a **WASM client
dispatching a result-keyed creating action** (a `CreatePoll`-shaped action:
create the entity, adopt the key its result carries) still blocks, and still
aborts the page, at the promote step — after the bind step this section fixed
already succeeded. Payload-keyed actions (`OpenPoll{pollId}`-shaped, the
attach path) are fully covered and do not block. Giving `assignPrimary` an
async form is a separate follow-up.
**The result-keyed *promote* step has since been covered too.** This section
made the **bind** half of a result-keyed action async (`ensureBoundAsync` →
`registerModelSharedAsync`). At the time of writing the **promote** half still
called the synchronous `IBackend::assignPrimary` — a `sendSync`, and so a nested
`QEventLoop`, on `QtWebSocketBackend` — which blocked and aborted a WASM page.
That is no longer true: `IBackend::assignPrimaryAsync` exists
([backend.md](backend.md#promotion--assignprimaryasync)),
`QtWebSocketBackend` overrides it, and `Bridge::assignHandlerPrimary` prefers it,
falling back to the synchronous call only when a backend returns `false`.

## Ownership and authorization

Expand Down
2 changes: 1 addition & 1 deletion docs/spec/forms/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ output of `glz::write_json_schema<A>()` to add seven annotation groups:

| Annotation | Scope | Contents |
|---|---|---|
| `required` | Top-level, and every nested-aggregate object schema (see [Nested aggregates (recursive, cycle-guarded)](#nested-aggregates-recursive-cycle-guarded)) | Array of field names that are **not** `std::optional<...>` and not listed in `A::optionalFields`. |
| `required` | Top-level, and every nested-aggregate object schema (see [Nested aggregates (recursive, cycle-guarded)](#nested-aggregates-recursive-cycle-guarded)) | Array of field names that are **not** `std::optional<...>` and not listed in `A::optionalFields`. Always written, overwriting whatever glaze produced: glaze never derives `required` from member types — it emits one only where a type declares `meta<V>::required` (and for a tagged variant's discriminator) — so morph does not rely on its absence. |
| `x-order` | Every property | The member's declaration index (0‑based), so a renderer lays fields out in declaration order regardless of JSON key ordering. |
| `x-decimalPlaces` | `Quantity` properties | The field's declared precision (`Quantity<U, Dec>::declaredDecimals`). |
| `x-unitAlternatives` | `Quantity` properties | Convertible display/entry units derived from `UnitTraits::relations`, each with `{id, display, decimals, num, den}` — `id`/`display`/`decimals` come from the alternative unit's `UnitMeta`, and `num`/`den` are the exact alternative-to-canonical ratio. Omitted entirely when the field's unit declares no convertible units. |
Expand Down
14 changes: 11 additions & 3 deletions docs/spec/journal/journal.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,8 +444,16 @@ already happened.
## IActionLog — the storage interface

A pure-virtual interface for durable, append-only storage of action entries.
Entries are never removed by the framework — this is a permanent record, unlike
`morph::offline::IOfflineQueue` (whose `markDone()` deletes items once retried).
No *entry-level* deletion API exists — there is nothing corresponding to
`morph::offline::IOfflineQueue::markDone()`, which deletes items once retried.
Two operations on the *shipped file implementation* — not on this interface —
do change what a subsequent `entries()` returns, and neither is an exception to
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 `FileActionLog`'s private
`repairTornTail()`, which discards a truncated trailing record and runs only
from that class's constructor. An `IActionLog` implementation over another sink
owes neither.

| Method | Signature | Purpose |
|---|---|---|
Expand Down Expand Up @@ -1138,7 +1146,7 @@ and `RemoteServer::setLogProvider(LogProvider)`, declared in `remote.hpp`. See
|---|---|---|
| `LogEntry` is a plain aggregate | **No `glz::meta`** | Same automatic reflection `BRIDGE_REGISTER_ACTION` uses; no manual schema maintenance. |
| Error path sharing | **`detail::throwOnGlazeError` for both `toJson`/`fromJson`** | `fromJson`'s failure is easy to test (malformed input); `toJson`'s is structurally unreachable for `LogEntry`. Routing both through one non-template function means the same compiled branch covers both, so `toJson`'s error path is exercised by `fromJson`'s tests. |
| Entries are never removed | **Append-only, no deletion API** | Permanent audit trail — unlike `IOfflineQueue` whose `markDone()` deletes retried items. |
| No entry-level deletion | **Append-only, no per-entry deletion API** | Permanent audit trail — unlike `IOfflineQueue` whose `markDone()` deletes retried items. `FileActionLog`'s `rotate()` and private `repairTornTail()` operate on the file, not on entries, and are not part of `IActionLog`. |
| Default log is a function-local static | **`detail::defaultActionLogState()` returns a `pair<mutex, shared_ptr>`** | Safe regardless of translation-unit init order, unlike a namespace-scope global. |
| `SessionLog::checkpoint` advances the watermark *before* forwarding | **At-most-once / forward-only** | A checkpoint is a forward-only commit point, not a transaction to retry: the watermark advances first, so a throwing durable sink drops that batch permanently. (`IOfflineQueue`'s retry semantics do *not* carry over — the shared shape is superficial.) |
| Checkpoint watermark is a committed-`seq` threshold, not an `_all` index | **Track committed state by entry identity** | `seq` is assigned once and never reused, so it stays a valid commit marker even as coalescing forwards fewer entries than it consumes and as `undoLast()` pops tail entries. A raw index into the mutable `_all` vector cannot: it silently shifts meaning when entries are removed, which is the root of the undo/coalescing incoherence this replaces. |
Expand Down
8 changes: 4 additions & 4 deletions docs/spec/offline/offline.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ The payload format is the caller's choice — JSON, binary-hex, plain text, etc.

#### `idempotencyKey`: deduping against the journal

`QueueItem::id` is **queue-local** — a durable queue re-presents the same logical
op with a fresh `id` after a restart, and the journal's `seq` is journal-local,
`QueueItem::id` is **queue-local** — both shipped durable queues re-present the
*stored* `id` after a restart, and the journal's `seq` is journal-local,
so the two subsystems share no identity. That is exactly the seam where an op can
be **double-applied**: the offline queue and the journal can each replay the same
logical operation with nothing to recognise it as already-applied.
Expand Down Expand Up @@ -559,7 +559,7 @@ and calls a caller-supplied `ReplayFunction` for each item.
| ctor | `SyncWorker(IOfflineQueue&, ReplayFunction, DeadLetterSink = nullptr)` | References the queue and the replay callable; the sink is an optional third argument. |
| ctor | `SyncWorker(IOfflineQueue&, DetailedReplayFunction, DeadLetterSink = nullptr)` | Same, taking the three-outcome callable. The two overloads are unambiguous — `ReplayOutcome` is a scoped enum, so neither return type implicitly converts to the other. The boolean overload adapts into this one, so `run()` implements a single contract. |
| `run()` | `SyncResult run()` | Drains the queue and replays each item. Concurrent calls are serialised by an internal mutex. Returns immediately if `stop()` was called before acquiring the lock. Emits the `queueDepth` metric once, with the drained item count, before replaying (see [observability.md](../core/observability.md)). |
| `stop()` | `void stop()` | Signals an in-progress `run()` to stop after the current item. One-shot — the flag resets at the start of the next `run()`. |
| `stop()` | `void stop()` | Signals an in-progress `run()` to stop after the current item. `run()` clears the flag at its start — but a `stop()` landing *during* a run leaves it set on return, so the next `run()` takes its early-out and drains nothing; work resumes on the run after that. |

**Retry & dead-letter (hard-coded cap, durable count):**

Expand Down Expand Up @@ -744,7 +744,7 @@ calling thread.

| Enumerator | Meaning |
|---|---|
| `Reconnected` | Backend reopened, made active, context bound, queue replay invoked. |
| `Reconnected` | Backend reopened, made active, context bound. Replay is invoked only if `shouldContinue()` still holds at that point — `Reconnected` can be returned without replaying. |
| `GaveUp` | Exhausted `maxAttempts` without a successful reconnect; stayed offline. |
| `Aborted` | `shouldContinue()` returned false before any reconnect attempt. |

Expand Down
23 changes: 14 additions & 9 deletions docs/spec/util/rational.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,16 +188,21 @@ 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.
- **`canonicalise`** — flips sign for a negative denominator (`numerator =
-numerator`) and takes `absoluteNumerator = numerator < 0 ? -numerator :
numerator`; both negate `INT64_MIN`. This is the shared sink for every
constructor and operator, so any path that lets `INT64_MIN` reach
canonicalisation is unsafe.

Only the wire codec (`setWire`) defends against this: it maps an `INT64_MIN`
- **`canonicalise`** — **no longer one of these.** 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.

The wire codec (`setWire`) also defends independently: it maps an `INT64_MIN`
`num`/`den` to `-INT64_MAX` *before* constructing, so untrusted input never
negates the trap value. In-code call sites get no such guard — keep operands
well inside the envelope above.
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.

### Checked arithmetic

Expand Down
Loading
Loading