Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1d90cae
core: escape control bytes in action and result JSON bodies
Yaraslaut Aug 6, 2026
f60c83c
core: add a client-side execute deadline (Bridge::setExecuteDeadline)
Yaraslaut Aug 7, 2026
ecf2726
core: cover the execute deadline's disarm and verify its header stand…
Yaraslaut Aug 7, 2026
7d88a8e
core: add an async register-or-attach/attach path for shared/keyed mo…
Yaraslaut Aug 7, 2026
8502d1e
core: never invoke an async attach's onDone from inside the dispatch …
Yaraslaut Aug 8, 2026
21a5ca2
core: give TimeoutScheduler a browser-timer build for single-threaded…
Yaraslaut Aug 8, 2026
ee04da7
core: close a switchBackend staleness race and two exception-safety g…
Yaraslaut Aug 10, 2026
18022ad
qt: cover the shared/keyed async wire methods against a real server
Yaraslaut Aug 8, 2026
5e684c3
tests: add regression coverage for finding 023 (Completion::onError s…
Aug 11, 2026
43a4b9a
util: add morph::units::toString() for Quantity, work around a libc++…
Aug 11, 2026
dc2f01f
build: probe three -Weverything suppressions instead of assuming clan…
Aug 11, 2026
23bd24c
build: suppress -Wc++20-compat and -Wdisabled-macro-expansion
Aug 11, 2026
f863cf9
build+docs: fix three more WASM-leg-only Clang diagnostics
Aug 11, 2026
60cc1e1
build: suppress GCC's -Wmissing-field-initializers for designated-ini…
Aug 11, 2026
1b102e6
build+tests: fix three MSVC/Windows portability gaps
Aug 11, 2026
2475c77
tests: add direct TimeoutScheduler unit coverage for the throw-swallo…
Aug 13, 2026
9c2d63a
tests: cover attachHandlerAsync/ensureBoundAsync's throwing-dispatch …
Aug 13, 2026
505d7e5
tests: cover ensureBound's already-bound no-op and ensureBoundAsync's…
Aug 13, 2026
19dedbb
tests: cover execute()'s throwing-key path and parkIfInFrame's double…
Aug 13, 2026
18c16f2
tests: genuinely reach attachHandlerAsync/ensureBoundAsync's 'binding…
Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# Global, build-wide: the Lightweight ORM (fetched into examples/common's
# ladder testkit and examples/bank) includes <Windows.h> from SqlStatement.hpp
# with no NOMINMAX/WIN32_LEAN_AND_MEAN guard of its own, so its `min`/`max`
# macros leak into every translation unit that (transitively) includes it and
# break any later std::min/std::max/std::numeric_limits<T>::min() call in the
# same TU (e.g. examples/common/clock.hpp). Defined here, before any
# subdirectory is added, so it reaches every target that could end up in such
# a TU, not just the ones that call apply_warnings().
if(WIN32)
add_compile_definitions(NOMINMAX WIN32_LEAN_AND_MEAN)
endif()

option(MORPH_BUILD_TESTS "Build tests" ON)
option(MORPH_BUILD_EXAMPLES "Build examples" ON)
option(MORPH_BUILD_BANK_EXAMPLE "Build the SQLite/Lightweight bank example (heavy deps)" OFF)
Expand Down Expand Up @@ -160,6 +172,7 @@ target_sources(morph
include/morph/core/executor.hpp
include/morph/core/strand.hpp
include/morph/core/completion.hpp
include/morph/core/timeout_scheduler.hpp
include/morph/core/model.hpp
include/morph/core/registry.hpp
include/morph/core/backend.hpp
Expand Down
60 changes: 57 additions & 3 deletions cmake/compiler_options.cmake
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
include(CheckCXXCompilerFlag)

# Probed once per configure (CMake caches each check_cxx_compiler_flag() result
# in CMakeCache.txt keyed by the result variable, regardless of how many
# targets call apply_warnings()) rather than unconditionally listed below:
# these three are recent enough additions to Clang's -Weverything set that
# Emscripten's bundled clang (pinned to an older release than the Linux/
# Windows Clang this project otherwise builds with — see
# .github/workflows/wasm-ladder.yml's EMSDK_VERSION) rejects them outright
# under -Werror with "unknown warning option", turning a *suppression* flag
# into the very error it exists to silence. A version-number cutoff would be
# equally correct but more fragile (would need updating every time either
# toolchain's version changes); probing the actual compiler is the standard,
# self-maintaining way to make -Weverything portable across Clang releases.
check_cxx_compiler_flag(-Wno-nrvo MORPH_CLANG_HAS_WNO_NRVO)
check_cxx_compiler_flag(-Wno-unsafe-buffer-usage-in-libc-call MORPH_CLANG_HAS_WNO_UNSAFE_BUFFER_USAGE_IN_LIBC_CALL)
check_cxx_compiler_flag(-Wno-c2y-extensions MORPH_CLANG_HAS_WNO_C2Y_EXTENSIONS)
check_cxx_compiler_flag(-Wno-missing-designated-field-initializers MORPH_CLANG_HAS_WNO_MISSING_DESIGNATED_FIELD_INITIALIZERS)

function(apply_warnings target)
target_compile_options(${target} PRIVATE
# ── MSVC ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -35,6 +54,14 @@ function(apply_warnings target)
$<$<CXX_COMPILER_ID:GNU>:
-Wall
-Wextra
# GCC's -Wextra implies -Wmissing-field-initializers, which (unlike
# Clang's narrower -Wmissing-designated-field-initializers, already
# suppressed above for the identical reason) fires on every field a
# designated initializer leaves unset -- flagging the same
# deliberately-partial DTO/config-style construction
# (morph::session::Context{.principal = ...} and its many
# siblings) as a defect, one diagnostic per omitted field.
-Wno-missing-field-initializers
-Wpedantic
-Wshadow
-Wnon-virtual-dtor
Expand Down Expand Up @@ -66,24 +93,51 @@ function(apply_warnings target)
-Wno-pre-c++17-compat-pedantic
-Wno-pre-c++20-compat
-Wno-pre-c++20-compat-pedantic
# -Wc++20-compat is the sibling of -Wpre-c++20-compat for a
# narrower set of syntax (consteval, implicit `typename` in alias
# templates) that only some Clang builds separate out from the
# pre-c++20-compat umbrella above — same "we target C++23"
# rationale, added once the WASM leg's Emscripten-bundled clang
# (older than the Linux/Windows clang this project otherwise
# builds with) was the first to actually split it out and fire it
# on model_key.hpp/quantity.hpp/forms.hpp/bridge.hpp.
-Wno-c++20-compat
# (b) Inherent to a header-only, templated library.
-Wno-weak-vtables # vtable emitted per TU for inline-virtual classes
-Wno-ctad-maybe-unsupported # CTAD on types without explicit deduction guides
-Wno-padded # struct tail/inter-member padding
-Wno-exit-time-destructors # function-local statics with non-trivial dtors
-Wno-global-constructors # non-trivial namespace-scope initializers
# Emscripten's sysroot stdio.h defines `#define stderr (stderr)`
# (a legal, intentional self-referential object-like macro used
# to make `stderr` a valid preprocessor token while still
# resolving to the libc symbol) -- logger.hpp's
# std::println(stderr, ...) call trips -Wdisabled-macro-expansion
# on that expansion. Not fixable in logger.hpp itself: the macro
# is the *platform's*, not this codebase's, and every other
# target's libc either doesn't define stderr as a macro at all or
# doesn't self-reference it this way.
-Wno-disabled-macro-expansion
# (c) Stylistic / opinionated noise, not defects.
-Wno-missing-noreturn
-Wno-nrvo # not eliding a trivial-type copy on return
# Deliberately-partial designated initialization of DTO/config-
# style aggregates (morph::session::Context{.principal = ...}
# and its many siblings across the ladder rungs) is this
# codebase's normal way to construct one with everything else
# left at its member default -- not an oversight this warning
# should flag. Probed like the other recent-Clang-only flags
# above: not every Clang release has this diagnostic yet.
$<$<BOOL:${MORPH_CLANG_HAS_WNO_MISSING_DESIGNATED_FIELD_INITIALIZERS}>:-Wno-missing-designated-field-initializers>
$<$<BOOL:${MORPH_CLANG_HAS_WNO_NRVO}>:-Wno-nrvo> # not eliding a trivial-type copy on return
-Wno-shadow-uncaptured-local # lambda param shadowing an uncaptured local
-Wno-documentation-unknown-command
-Wno-unsafe-buffer-usage # flags all pointer arithmetic; needs a hardened API
-Wno-unsafe-buffer-usage-in-libc-call
$<$<BOOL:${MORPH_CLANG_HAS_WNO_UNSAFE_BUFFER_USAGE_IN_LIBC_CALL}>:-Wno-unsafe-buffer-usage-in-libc-call>
-Wno-float-equal # exact == is intentional in the value/rational tests
# (d) Conflicts with a warning we deliberately keep.
-Wno-covered-switch-default # collides with -Wswitch-enum + -Wswitch-default
# (e) Third-party test macros.
-Wno-c2y-extensions # Catch2 TEST_CASE expands __COUNTER__
$<$<BOOL:${MORPH_CLANG_HAS_WNO_C2Y_EXTENSIONS}>:-Wno-c2y-extensions> # Catch2 TEST_CASE expands __COUNTER__
-Wno-unused-member-function # Catch2/test-fixture helper members
-Wno-unneeded-member-function
# (f) Clang 22 (Homebrew, macOS libc++) added thread-safety-analysis
Expand Down
60 changes: 50 additions & 10 deletions docs/spec/core/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,10 @@ re-registration loop already gave the binding a fresh id on the new backend,
which a stale reply must not overwrite).

**Scope.** Only the plain (non-shared) registration path uses this — a
`BridgeHandler`'s initial construction. `registerModelShared`/`attachModel`
(shared/keyed handlers) and the re-registration `switchBackend()`/the
reconnect handler perform after a backend swap remain synchronous; giving
those an async path too is a larger change to `Bridge`'s locking model, left
for a future issue if it proves necessary.
`BridgeHandler`'s initial construction. The re-registration `switchBackend()`
and the reconnect handler perform after a backend swap remains synchronous;
giving that an async path too is a larger change to `Bridge`'s locking model,
left for a future issue if it proves necessary.

`QtWebSocketBackend` is the one backend that currently overrides this, gated
by `QtWebSocketBackendConfig::asyncRegistrationEnabled` (default `false` — see
Expand All @@ -209,16 +208,52 @@ queue is drained by `cancelPending`, which still invokes each queued request's
`onError` exactly once, exactly like an in-flight (already-sent) registration
would. See `QtWebSocketBackend`'s own section below.

### Shared/keyed registration — `registerModelSharedAsync` / `attachModelAsync`

`registerModelShared` and `attachModel` have the same problem for the same
reason, reached by a different route: a keyed screen's first payload-keyed
`execute()` attaches, and on a wire backend that attach blocks in `sendSync`,
which aborts a WASM main thread. Both therefore have an optional non-blocking
counterpart with `registerModelAsync`'s exact shape and contract —
`false` by default, `true` plus exactly one later callback when a backend opts
in:

| Virtual | Synchronous counterpart | Preferred by |
|---|---|---|
| `registerModelSharedAsync(typeId, factory, identity, onRegistered, onError)` | `registerModelShared` | `Bridge::ensureBoundAsync` |
| `attachModelAsync(typeId, factory, identity, current, onRegistered, onError)` | `attachModel` | `Bridge::attachHandlerAsync` |

`QtWebSocketBackend` implements both behind the same
`asyncRegistrationEnabled` flag, reusing the same `callId`-keyed pending map
(reply routing is verb-agnostic — a `register`, a shared `register`, and an
`attach` all reply the same way). An empty `identity.primary` degrades to
`registerModelAsync`, mirroring the synchronous methods' degrade-to-private
behaviour.

Unlike the synchronous `attachModel`'s default implementation,
`attachModelAsync` does **not** release `current` itself: an overriding backend
is behind a wire protocol whose single `attach` request re-points server-side,
leaving nothing to deregister — the same division of responsibility
`QtWebSocketBackend::attachModel` already follows for a non-empty primary.

`BridgeHandler::execute()`'s public signature and contract are unchanged; see
[shared_instances.md](shared_instances.md), "Async register-or-attach and
attach", for the caller-visible story and the `_attachMtx` locking rule these
two `Bridge` methods must obey.

## Error types

Four exception types are thrown into in-flight `Completion`s:
Five exception types are thrown into in-flight `Completion`s. The first four are
raised by a backend; `ClientTimeoutError` is raised by `Bridge` itself, but is
declared alongside them so callers catch every dispatch failure from one header:

| Type | Trigger | Purpose |
|---|---|---|
| `BackendChangedError` | `Bridge::switchBackend()` runs | GUI can retry on the new backend or surface a "backend changed" message. |
| `BridgeDestroyedError` | `Bridge` is destroyed | In-flight completions are cancelled because the bridge is gone. |
| `DisconnectedError` | Transport drops mid-call (e.g. WebSocket disconnect) | Framework retries the call on reconnect if the backend supports it; otherwise the GUI's `.onError(...)` runs. |
| `TimeoutError` | Server-side `LimitPolicy::executeTimeout` elapses | Distinguishes a bounded-wait timeout from any other `err` reply, so callers can retry or surface a specific "request timed out" message. |
| `ClientTimeoutError` | Client-side `Bridge::setExecuteDeadline` elapses with *no* reply of any kind | Bounds the caller's wait when nothing comes back at all (a dropped frame, a hung server). Unlike `TimeoutError` it carries no evidence the server ever saw the request — see [`completion.md`](completion.md), "Client-side execute deadline". |

## `LocalBackend` — in-process execution

Expand Down Expand Up @@ -430,11 +465,15 @@ A server-side execute timeout surfaces to a caller as `morph::backend::TimeoutEr
than a generic `std::runtime_error`, on both `SimulatedRemoteBackend` and
`QtWebSocketBackend`.

The background timer that enforces `executeTimeout` is `detail::TimeoutScheduler` —
a single dedicated thread per `RemoteServer` (mirroring `NetworkMonitor`'s
The background timer that enforces `executeTimeout` is
`morph::async::detail::TimeoutScheduler` (`include/morph/core/timeout_scheduler.hpp`)
— a single dedicated thread per `RemoteServer` (mirroring `NetworkMonitor`'s
condition-variable wait loop), lazily started by `setLimitPolicy` the first time
`executeTimeout` is configured, so a server that never uses the feature pays no
extra thread.
extra thread. The class lives in `morph::async::detail` rather than
`morph::backend::detail` because `Bridge` uses the same primitive for the
*client*-side `setExecuteDeadline` — see [`completion.md`](completion.md),
"Client-side execute deadline".

### Connection scopes

Expand Down Expand Up @@ -1202,6 +1241,7 @@ thread to marshal onto.
| `BridgeDestroyedError` | `std::runtime_error` | `"bridge destroyed before completion resolved"` |
| `DisconnectedError` | `std::runtime_error` | `"transport disconnected before completion resolved"` |
| `TimeoutError` | `std::runtime_error` | `"execute timed out on the server"` |
| `ClientTimeoutError` | `std::runtime_error` | `"execute timed out waiting for any reply"` |

### `LocalBackend`

Expand Down Expand Up @@ -1361,7 +1401,7 @@ not a behavior change to the existing loopback-only default.
| 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. |
| `executeTimeout` implementation | A dedicated, lazily-started background thread (`detail::TimeoutScheduler`) per `RemoteServer`, not a per-call thread | `IExecutor` has no delayed-post primitive and `RemoteServer` is transport-agnostic (cannot assume Qt's `QTimer`). One thread amortizes across every timed call; it is only started the first time `executeTimeout` is actually configured, so a server that never uses the feature pays no cost. |
| `executeTimeout` implementation | A dedicated, lazily-started background thread (`morph::async::detail::TimeoutScheduler`) per `RemoteServer`, not a per-call thread | `IExecutor` has no delayed-post primitive and `RemoteServer` is transport-agnostic (cannot assume Qt's `QTimer`). One thread amortizes across every timed call; it is only started the first time `executeTimeout` is actually configured, so a server that never uses the feature pays no cost. |
| `messagesPerSecond` algorithm | Per-connection token bucket, capacity = rate, continuous refill, drop (not close) on empty | Simplest correct rate limiter; allows a legitimate one-second burst without penalizing an otherwise well-behaved client. Dropping (vs. closing) keeps a transient burst from taking down the connection — pair with `LimitPolicy::executeTimeout` if bounded caller-side waiting is also needed. |
| Graceful shutdown drains via a shared in-flight counter, not a new `IExecutor::waitIdle` | `RemoteServer` counts its own accepted-but-unreplied executes rather than adding a general drain API to `IExecutor`/`StrandExecutor` | The drain condition morph can define precisely — "every accepted execute has replied" — lives at the server layer, where the work is counted; executor.md's "no graceful drain / `waitIdle`" limitation is deliberately left as-is for raw executor users. |
| Backend-change-awareness captured at registration | `IModelHolder::isBackendChangeAware()` (compile-time answer per model type) + `LocalBackend::_changeAware`, maintained by `registerModel`/`deregisterModel` | Replaces a per-`notifyBackendChanged`-call `dynamic_cast` sweep over every live model with a virtual query done once at registration, and a lookup restricted to the models that actually opted in. No RTTI dependency; cost is O(change-aware models) instead of O(all models) under `_regMtx`. No change to the model-facing contract (`IBackendChangedSink`, `BackendChangedMixin`) or to when/where `onBackendChanged()` runs. |
Expand Down
Loading
Loading