Skip to content

Production-hardening + GUI enhancement program (28 items) + 2 security fixes - #19

Merged
Yaraslaut merged 199 commits into
masterfrom
feat/header-subdomain-layout
Jul 27, 2026
Merged

Production-hardening + GUI enhancement program (28 items) + 2 security fixes#19
Yaraslaut merged 199 commits into
masterfrom
feat/header-subdomain-layout

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Summary

Implements all 28 items tracked in docs/todo.md's production-hardening and GUI-enhancement checklist, plus fixes two real memory-safety/correctness bugs the new fuzz harness found along the way. 176 commits, ~234 files changed.

Every item is opt-in or backward-compatible by default — nothing changes existing behavior unless explicitly enabled. docs/planned/ no longer holds any implemented-item specs; the authoritative current-state design lives entirely in docs/spec/.

§A — Remote-mode hardening (7/7)

  • A1 Server-side action validation (ActionValidator/ValidationError now enforced on every dispatch path, not just the client)
  • A2 Register authorization (IAuthorizer::authorizeRegister) + opaque (non-sequential) model ids
  • A3 Transport-level resource limits (RemoteServer::LimitPolicy, QtWebSocketServerConfig connection/rate/timeout knobs)
  • A4 TLS peer verification as the enforced default (tlsVerifyingConfig/tlsPinnedConfig, non-loopback plaintext-bind guard)
  • A5 Vetted HMAC injection (libsodium/OpenSSL adapters, MORPH_REQUIRE_VETTED_HMAC build guard)
  • A6 Protocol version negotiation ("hello" handshake) + forward-compatible action decoding
  • A7 Connection-scoped model cleanup (ConnectionId scope, reclaims models on disconnect)

§B — Durability & data-integrity (4/4)

  • B1 Durable offline-queue hooks (QueueItem::attempts, DeadLetterSink)
  • B2 Transactional outbox (journal::OutboxRelay, idempotency-key dedup)
  • B3 Shipped durable IOfflineQueue (FileOfflineQueue, SqliteOfflineQueue)
  • B4 Journal format versioning + FileActionLog::rotate()

§C — Operational readiness (5/5)

  • C1 Observability (morph::observe metrics/trace seam, RemoteServer::health())
  • C2 Non-Qt transport (morph::net: hand-rolled RFC 6455 WebSocket over raw sockets, verified wire-interoperable with the Qt transport both directions)
  • C3 Fuzz/soak/load testing harnesses (MORPH_BUILD_FUZZERS/MORPH_BUILD_LOAD_TESTS)
  • C4 Compile-time onBackendChanged dispatch (drops the dynamic_cast sweep, parity-tested)
  • C5 Graceful shutdown & drain (beginShutdown()/drainedWithin()/closeGracefully())

§D — Process (2/2)

  • D1 Spec↔code drift-guard CI (docs/spec/pinned_facts.toml, pins enum cardinalities/constants/error strings)
  • D2 API stability / 1.0 versioning policy (docs/spec/VERSIONING.md, morph::version)

§E — GUI enhancement program (10/10)

Field metadata, layout/grouping, widget hints, cross-field rules, computed fields, dependent choices, i18n, a promoted Qt/QML renderer toolkit (src/qt/forms), collections/views, and wizards/app-shell navigation — see docs/spec/forms/forms.md's new "Design principle" section for the overview.

Security fixes (found by C3's own fuzz harness, fixed before merge)

  1. skip_ws heap-buffer-overflow: every glz::read<> call site accepting an arbitrary string_view left glaze's null_terminated option at its unsafe default, reachable by a 5-byte input to any RemoteServer. Fixed at all 4 affected sites.
  2. err-reply round-trip failure: glaze's writer doesn't escape ASCII control bytes, so an error reply echoing untrusted text (an unrecognized kind, or an exception's what()) could serve invalid JSON. Fixed in wire::makeErr.

Both have permanent regression cases under tests/fuzz/findings/ and dedicated Catch2 tests in test_wire_hardening.cpp.

Test plan

  • Full morph_tests suite green throughout (only one pre-existing, unrelated Node.js example test fails — forms_repl_roundtrip)
  • Qt suite (MORPH_BUILD_QT=ON, MORPH_BUILD_FORMS_QML=ON) green
  • morph::net cross-transport interop verified both directions (SocketBackendQtWebSocketServer, QtWebSocketBackendSocketServer)
  • Fuzz harnesses run 10M+/3.7M iterations post-fix with zero crashes (vs. crashing within seconds pre-fix)
  • Doxygen strict doc build (WARN_AS_ERROR=FAIL_ON_WARNINGS) clean
  • MORPH_BUILD_OFFLINE_SQLITE=ON and MORPH_REQUIRE_VETTED_HMAC=ON guarded builds verified separately

🤖 Generated with Claude Code

Yaraslaut added 30 commits July 24, 2026 15:22
Add a fourth optional IAuthorizer hook, authorizeRegister(ctx, modelType),
consulted by RemoteServer on every register envelope after authenticating
the caller and before constructing the instance. Defaults to allow, so
AllowAllAuthorizer and a plain SigningAuthorizer keep today's behavior;
a deployer opts in to bound who may create model instances.
Add morph::backend::detail::OpaqueIdGenerator: a 4-round keyed Feistel
permutation over the 64-bit space, keyed once from std::random_device at
RemoteServer construction. RemoteServer now assigns ids by running its
monotonic counter through the generator instead of handing out
_nextId.fetch_add(1) + 1 directly, so ids stay unique (Feistel networks are
bijections) but are no longer sequential or predictable from an observed id.
Wire format is unchanged (ModelId stays a plain uint64_t).
authorizeRegister and opaque model ids are now implemented (RemoteServer
enforces both, both opt-in/backward-compatible). Fold
docs/planned/instance_authorization.md's design into docs/spec/session/session.md,
docs/spec/core/backend.md, and docs/spec/security.md in present tense, and
delete the planned file per project convention (only docs/spec/ holds specs
for implemented work).
…cement

Adds the opt-in LimitPolicy struct (executeTimeout, maxLiveModels,
maxInFlightExecutes) and RemoteServer::setLimitPolicy(), and enforces
maxLiveModels in the register branch of dispatchMessage. All fields default
to 0 (unbounded), so an unconfigured server is unaffected.
…rcement

Adds an atomic in-flight-execute counter, incremented before dispatchExecute
posts to the strand and decremented right before the reply is sent (success
or exception). A caller over maxInFlightExecutes gets err "server busy"
before any authorization work runs.
…+ TimeoutError

Adds morph::backend::TimeoutError (alongside DisconnectedError et al.) and a
lazily-started detail::TimeoutScheduler background thread that enforces
LimitPolicy::executeTimeout in dispatchExecute. A shared once-flag guard
("complete") ensures the timeout path and the strand path race safely and
handle()'s reply-exactly-once contract holds; the losing side's result is
discarded and the in-flight counter is decremented exactly once regardless of
which path wins. SimulatedRemoteBackend and QtWebSocketBackend both translate
an err "timeout" reply into TimeoutError for callers.
…eBytes

Introduces morph::qt::QtWebSocketServerConfig (aliased QtWebSocketServer::Config)
threaded through a new constructor parameter, defaulting to unbounded except
maxMessageBytes which defaults to wire::kMaxEnvelopeBytes (today's only bound).
onNewConnection refuses a connection beyond maxConnections before wiring any
signal; onTextMessage rejects an oversized frame before it reaches
RemoteServer::handle().
… rate limit

_clients becomes a QWebSocket* -> ClientState map holding a per-connection
token bucket (capacity = messagesPerSecond, continuous refill). A frame that
finds an empty bucket is dropped silently, not replied to or closed, so a
single burst does not take down an otherwise well-behaved client.
A one-shot per-connection QTimer enforces handshakeTimeout (cancelled on the
first frame); a shared ~1s housekeeping QTimer sweeps _clients and closes any
connection idle past idleTimeout. Both default to 0 (disabled), reproducing
today's behavior. Verified against the Doxygen WARN_AS_ERROR doc-completeness
gate (cmake --build build --target doc, exit 0, no warnings).
…etire planned spec

Documents the now-implemented RemoteServer::LimitPolicy and
QtWebSocketServerConfig resource limits in present tense across
docs/spec/core/backend.md (new LimitPolicy section, updated QtWebSocketServer
section, Error types/API reference/Design decisions tables), docs/spec/security.md
(hardening checklist + Testing section), and docs/spec/core/wire.md
(cross-reference). Retires the A3 todo.md entry and deletes
docs/planned/transport_limits.md now that the design has landed.
…helpers

Header-only QSslConfiguration factory helpers in qt_tls.hpp make CA-verified
and pinned-certificate TLS the documented, easy path for QtWebSocketBackend,
alongside a clearly-named insecure escape hatch for local dev/tests.
server.crt now carries subjectAltName=IP:127.0.0.1 so Qt's hostname check
(which matches literal-IP peers only against SAN entries, never the CN)
passes for VerifyPeer-mode tests. mitm.crt/mitm.key is a second, independent
self-signed pair standing in for an attacker who cannot present the real
server's private key.
…a mitm

Adds three integration tests exercising the full TLS handshake path: a
pinned client reaches the real server, is refused by a server presenting a
different (mitm) certificate, and tlsInsecureNoVerify connects to both --
demonstrating exactly why VerifyNone is MITM-vulnerable.
… plaintext binds

Extends the QtWebSocketServerConfig struct (already carrying A3's
maxConnections/maxMessageBytes/messagesPerSecond/handshakeTimeout/idleTimeout)
with bindAddress and allowPlaintextExposure. listen() now refuses a
non-loopback bind with no TLS configuration and allowPlaintextExposure left
at its default false, logging at error level instead of silently starting a
plaintext, off-host-reachable server. Loopback binds and any TLS-configured
bind are unaffected -- fully backward compatible.
morph_qt_tls_example runs a real server (certs/server.crt) and a stand-in
mitm server (certs/mitm.crt) on loopback, then demonstrates
tlsPinnedConfig() reaching the real server and being refused by the mitm
one, contrasted with tlsInsecureNoVerify() connecting to both. Registered
as the qt_tls_example_runs ctest.
…the planned spec

Documents the now-implemented qt_tls.hpp helpers (tlsVerifyingConfig/
tlsPinnedConfig/tlsInsecureNoVerify) and QtWebSocketServerConfig's exposure
guard (bindAddress/allowPlaintextExposure) in present tense across
docs/spec/security.md (Transport security, hardening checklist, Testing) and
docs/spec/core/backend.md (QtWebSocketBackend/QtWebSocketServer prose, new
QtWebSocketServerConfig API reference table). Retires the A4 todo.md entry
and deletes docs/planned/tls_peer_verification.md now that the design has
landed.
openConnection()/closeConnection() give a transport a way to scope model
registrations to one connection. This step only adds the bookkeeping and
lifecycle; register/deregister are not wired to it yet.
…d handle overload

register recorded under a non-zero cid now joins that connection's scope
(next to _models/_owners, under the same _regMtx); deregister removes it
from the scope too, so closeConnection is idempotent after an explicit
deregister. The unscoped two-argument handle()/handleInline() are unchanged
(cid defaults to 0). closeConnection deliberately bypasses IAuthorizer.
QtWebSocketServer now opts every client into RemoteServer's connection
scope: openConnection() on accept, the scoped handle(msg, reply, cid) on
every frame, and closeConnection() on disconnect and on close(). A client
crash or dropped socket now reclaims its models instead of leaking them
until process exit.

ClientState (added by the sibling transport-limits plan) grows a `cid`
field rather than introducing a separate ClientConnection wrapper, since
_clients is already keyed by socket pointer.
…spec

RemoteServer's ConnectionId scope and QtWebSocketServer's end-to-end wiring
are now implemented; backend.md documents them as current, present-tense
behavior (API reference, design decisions, Limitations, and the
QtWebSocketBackend client-side deregisterModel note) and the superseded
docs/planned/connection_scoped_cleanup.md is removed. Retires the A7
todo.md entry, matching the convention the three sibling plans used for
their own planned-spec retirements.
…AC gates the default mac

Deviates from the plan's try_compile() invocation: LINK_LIBRARIES only
accepts imported targets from the calling project, and morph/glaze::glaze
are plain (non-imported) INTERFACE libraries here, so linking morph::morph
directly fails with "the target was not found". Fixed by forwarding their
INTERFACE_INCLUDE_DIRECTORIES via -DINCLUDE_DIRECTORIES= CMAKE_FLAGS instead,
which try_compile's generated project turns into include_directories().
Deviates from the plan's CMakeLists.txt content: this directory is added
via MORPH_BUILD_EXAMPLES, which is processed before the top-level
MORPH_BUILD_TESTS block that resolves Catch2, so Catch2's `Catch` CMake
module isn't yet on CMAKE_MODULE_PATH when include(Catch) runs here (the
Catch2::Catch2WithMain *target* itself still resolves fine since CMake
defers target_link_libraries() resolution). Fixed the same way
examples/bank/CMakeLists.txt already does: an independent
find_package(Catch2 3 CONFIG QUIET) plus list(APPEND CMAKE_MODULE_PATH
${Catch2_DIR}) before include(Catch), warning and skipping the test
executable if Catch2 CONFIG isn't found.
Applies the same Catch2-availability fix as the libsodium block (see prior
commit): find_package(Catch2 3 CONFIG QUIET) + CMAKE_MODULE_PATH append
before include(Catch), since this directory is processed before the
top-level MORPH_BUILD_TESTS block resolves Catch2.
Yaraslaut and others added 2 commits July 24, 2026 15:22
fuzz_wire_decode and fuzz_dispatch_execute both found real bugs on their
first campaign run (logged as follow-up in testing_strategy.md's "Known
findings"). Fix both now rather than leave them open:

1. skip_ws heap-buffer-overflow: every glz::read<> call site that accepts
   an arbitrary string_view (wire::decode, journal::fromJson,
   BRIDGE_REGISTER_ACTION's generated fromJson/resultFromJson,
   FileOfflineQueue::fromJson) left glaze's null_terminated option at its
   default (true), so skip_ws assumed a terminator byte a bare view never
   guarantees. A 5-byte input was enough to trip an ASan
   heap-buffer-overflow, reachable by any peer sending a handful of bytes
   to a RemoteServer. Fixed by setting .null_terminated = false at each
   site — session_auth.hpp's token-claims decode was checked and needs no
   change, since it parses a real std::string with the standard's
   null-terminator guarantee, not a view.

2. Unescaped control bytes broke the err-reply round-trip: glaze's writer
   escapes `"` and `\` but not ASCII control bytes, so an err reply
   echoing untrusted text containing one (an unrecognized Envelope::kind,
   or an exception's what()) served invalid JSON that glaze's own reader
   then rejected. Fixed in wire::makeErr, the single choke point every err
   reply goes through, by replacing control bytes with a printable \xHH
   placeholder before they reach the writer.

Both original crashing inputs are preserved as permanent regression cases
under tests/fuzz/findings/, and covered by dedicated Catch2 tests in
test_wire_hardening.cpp. Verified via 10M+ and 3.7M fuzz runs respectively
with zero further crashes.
…tures

Adds examples/concepts/, one small self-contained Catch2 test file per
recently-landed hardening feature (journal/outbox, offline queue durability,
server-side validation, register authorization, transport limits, protocol
versioning, connection scoping, observability, graceful shutdown), so
newcomers can learn each concept's golden-path usage from a single focused
file instead of the exhaustive tests/ suite. Wired into the root build
unconditionally under MORPH_BUILD_EXAMPLES, matching examples/forms's
treatment (no external dependency, unlike examples/vetted_hmac's opt-in
crypto gating).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Yaraslaut
Yaraslaut force-pushed the feat/header-subdomain-layout branch from b480a21 to 06376c6 Compare July 24, 2026 13:24
Yaraslaut and others added 5 commits July 24, 2026 16:49
…ackage

Doxygen 1.9.8 (through at least 1.14.0) misreports several correctly
documented symbols as undocumented: two template overloads of the same
free function, and a function immediately following a file-level doc
comment both trip its param/return association. Verified via Docker
against the exact CI environment that 1.15.0+ resolves it with zero
source changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- forms.md: fix 3 dead cross-references left over from the gui_overview.md
  fold-in, remove a stale "(reserved)" label on FieldMeta::widget that
  contradicted its documented behavior elsewhere, add the missing
  computedFields exclusion to the required-ness rule text (confirmed
  against forms.hpp's actual allRequiredEngaged implementation), and
  define "engaged"/"wire"/"Tier-1"/"Tier-2" at first use instead of
  assuming them.
- choice.md/views.md/workflows_navigation.md: anchor bare forms.md links
  to their actual sections, add a concrete viewSchemaJson example, and
  reconcile the informal vs. formal "screen" terminology.
- widget_hints.md: reuse the existing "infer by default, declare to
  override" wording instead of an undefined "umbrella program" paraphrase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- README: add the missing lab_wizard.hpp file-table entry, a suggested
  reading order, and a Views/SamplesView walkthrough section (the
  feature was only reachable via the legacy Main.qml, unmentioned
  anywhere in the guided walkthrough).
- lab_model.hpp: frame RecordMeasurement as the deliberate combined
  showcase it is, and explain the BRIDGE_REGISTER_*/Loggable::No and
  glz::json_schema-subset choices inline instead of leaving them
  unexplained.
- lab_units.hpp: clarify that the consteval operators' `throw` is a
  compile-time-only error, never a runtime exception.
- lab_schemas.hpp: note why schemasJson() is hand-unrolled rather than a
  loop.
- main.cpp: point C++-API-focused readers past the embedded HTML/CSS/JS
  block to where the Forms API resumes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Make the CapturedReply comment, the Glaze file-scope-linkage rationale,
and the "Reach for this when" banner consistent across all 7 files
(previously only some had each). Replace a comment-only "ids are opaque"
claim in register_authorization_and_ids.cpp with a real passing
assertion. Label queue.enqueue's second argument as the idempotencyKey,
comment LogEntry::principal on first appearance, note the
glz::read_json truthy-means-error convention, and add a reading-order
note to the README.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…cate()

Several tests were failing against current, correct behavior rather than
a regression:

- test_repl.sh: ComputeDryDensity's expected precision was stale (dp:3)
  from before volume's declared-precision override (4) existed --
  Rational::dividedBy is documented to take max(lhs.dp, rhs.dp), so dp:4
  is correct. RecordMeasurement/DeleteSample's expected error messages
  assumed the model's own throw would run, but both actions have since
  gained a validate() member that morph::model::ActionValidator now
  intercepts first (the model-level throw is documented as
  defense-in-depth, not the primary gate -- see forms.md, "Security /
  trust boundary").
- bank tests (test_transaction/test_payee/test_card/test_account/
  test_auth): five DTOs (Transfer, AddPayee, ChangePin, OpenAccount,
  RegisterUser) gained their own validate() implementing exactly the
  checks these tests probe, so morph::model::ValidationError now fires
  before the model's own bank::ValidationError would. Updated each
  REQUIRE_THROWS_AS to expect the framework-level type, with a comment
  explaining why.
- test_remote.cpp: RemoteServer::dispatchExecute clears Context::principal
  before dispatch unless the installed IAuthorizer::authenticate() vouches
  for it (documented, deliberate hardening against passing an unverified
  client claim through as authoritative). NoCloseAuthorizer only overrode
  authorize(), so "olivia-remote" was being scrubbed server-side. Added an
  authenticate() override that trusts the client-supplied principal,
  appropriate for this test-only authorizer.

Verified: full project build (all tests, examples, Qt/QML forms, bank +
bank GUI, vetted_hmac, net, offline_sqlite) and complete serial ctest run
now pass 886/886.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Yaraslaut
Yaraslaut force-pushed the feat/header-subdomain-layout branch from 164fe25 to 4450a47 Compare July 24, 2026 15:33
- remote.hpp: OpaqueIdGenerator's static_cast<uint32_t>(rd()) is flagged
  by GCC's -Wuseless-cast (std::random_device::result_type is literally
  uint32_t on this standard library), breaking every GCC-based CI job
  (gcc-debug, gcc-release, Qt6 WebSockets, Valgrind memcheck) with
  -Werror. The cast itself is a legitimate portability guard for a
  standard library where result_type differs, so keep it and suppress
  just this warning on real GCC (matching the existing precedent in
  rational.hpp), rather than removing it.

- test_computed_fields.cpp: renamed its file-scope CFUnit enum (and every
  reference to it) to CFLineUnit. It collided with
  test_forms_conformance_corpus.cpp's own unrelated, differently-defined
  CFUnit -- both are file-scope (not anonymous-namespaced) in translation
  units linked into the same morph_tests binary, so this was a genuine ODR
  violation: two conflicting definitions of the same external-linkage
  symbol. The linker silently picked one definition for both call sites,
  which happened to work under clang but resolved to the wrong one under
  GCC, failing Conformance corpus: CFQuantityAlternatives with the wrong
  unit ids/decimals.

Verified full builds and complete serial ctest runs green on both
compilers: gcc-debug (757/757) and clang (886/886, full config with
Qt/bank/net enabled).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Yaraslaut
Yaraslaut force-pushed the feat/header-subdomain-layout branch from 4450a47 to 0e8ea62 Compare July 24, 2026 15:53
Yaraslaut and others added 17 commits July 24, 2026 18:26
…lity/registry)

Two lines in bridge.hpp (installReconnectHandler, recomputeAll) were
llvm-cov region-mapping artifacts -- a statement immediately following a
closing brace loses its counter even though it always runs; moving each
into its preceding scope (neither acquires a lock or has other
side effects that make this unsafe) fixes the miscount without changing
behavior.

The other three gaps were real, if narrow, untested branches:
- IModelHolder::onBackendChanged's base no-op body is unreachable through
  any real ModelHolder<Model> (which always overrides it), so a minimal
  stub subclass exercises it directly.
- observability::detail::beginSpan's defensive fallback (traceOn set
  without a beginSpan hook) can only happen by reaching past
  setTraceSink's own invariant, which a test does deliberately.
- registry.hpp's toJson/resultToJson glz::write_json error paths needed
  glaze's one natural write-time failure: a tagged variant whose active
  alternative is a null smart pointer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYoDXWApxfP8K5jQiaQ2LA
FileActionLog's constructor opened _file via fopen() before rebuilding
the idempotencyKey dedup set from entries() -- but entries() throws on
interior corruption, which is exactly the condition
test_journal_format_versioning.cpp's mid-file-corruption test injects.
When that throw fires, the constructor never completes, so the
destructor never runs, and the already-open FILE* leaks for the rest
of the process. On Windows this blocks the temp file's later removal
(no unlink-while-open semantics), which is what showed up in CI as a
timeout. Verified with a standalone fd-count probe: the old ordering
leaks one fd on the throwing path, the new ordering (rebuild the dedup
set first, since entries() reads through its own ifstream independent
of _file; open _file only after that succeeds) leaks none. Confirmed
against a from-source build of the pre-fix header on the same probe.

test_file_offline_queue.cpp's idempotency-dedup test declared its
FileOfflineQueue in the test case's outer scope and then called
std::filesystem::remove() on its backing file while the queue -- and
its open file handle -- was still alive, unlike every other test in
the file. Harmless on POSIX (unlink works on open files) but a
guaranteed failure on Windows; scoped the queue the same way the
neighboring tests already do.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYoDXWApxfP8K5jQiaQ2LA
glaze 7.4 emits ASCII control bytes unescaped, which broke `encode` two
ways depending on where such a byte landed:

- Invalid output. RFC 8259 requires U+0000..U+001F to be escaped and
  glaze's own reader enforces it, so an envelope carrying a raw 0x0B
  anywhere serialized to JSON the peer's `decode` threw on.
- Silent corruption. With a `\` or `"` earlier in the same string, the
  writer's chunked fast path rewrote such a byte as *two* 0x00 bytes.
  The payload was destroyed before transmission and the envelope still
  decoded, so nothing downstream could detect it.

`makeErr`'s sanitizer only ever covered `message`; `body`, `modelType`,
`actionType`, `contextKey`, `typeId` and the session's principal/token
carry caller data that must round-trip byte-for-byte, so substitution is
the wrong instrument for them. Fixed at the writer via glaze's
documented `escape_control_characters` option, which is lossless both
ways. `makeErr` keeps substituting for a different reason now: an err
message is log-bound text, and a raw 0x1B would carry an ANSI escape
into the reader's terminal.

Also adds `detail::peekCallId`, a bounded prefix scan letting a
transport address a reply to a frame it deliberately never decoded.
Replying with callId 0 is not a harmless degradation: 0 is the client's
synchronous-reply discriminator, so such a reply resumes an unrelated
parked register/deregister with another call's result.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
Four transport defects, each unrecoverable at runtime:

- Reconnect deadlock. `onConnected()` ran the reconnect handler inline
  from the I/O thread *before* `readLoop()` started. A handler doing
  what reconnect handlers exist to do — re-registering models, via
  `sendSync` — then waited on `_syncCv` for a reply only that same
  thread could deliver. No timeout, so the transport wedged forever.
  Handlers now run on a dedicated thread; requests coalesce and a
  throwing handler no longer takes the thread down.

- `close()` took a connection's write mutex before `shutdownBoth()`. A
  client thread blocked in `sendAll` against a stalled peer holds that
  mutex until the send completes, and the shutdown is exactly what
  unblocks it — so `close()`, reached from the destructor, waited
  forever on the thing it was trying to interrupt.

- `SocketServer` never opened or closed connection scopes: the
  reclamation machinery was wired into `QtWebSocketServer` only, so
  every model registered over this transport outlived its connection.

- `WsFrameReader` rejected all fragmented frames. A peer fragments
  whenever a message exceeds its outgoing frame size and Qt defaults
  that to 512 KiB, so this broke interop with the transport this
  project ships. Now reassembled, with control frames passing through
  mid-message and the reassembled total bounded.

Also retries EINTR in `accept()` (a stray signal killed the accept
loop) and fixes GCC -Werror violations across morph::net, which had
never been compiled by GCC anywhere in CI.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
`_connectionScopes[cid].insert(mid)` used operator[], which
default-constructs. `handle()` posts to the worker pool while
`closeConnection()` runs synchronously on the transport's disconnect
callback, so a client that registers and immediately drops its socket
genuinely interleaves the two — and the resurrected scope was never
closed again, because nothing closes a scope twice. Every model on that
dead id became unreclaimable: an unbounded leak that, with
`maxLiveModels` set, wedges the server permanently at "too many
models". Now looked up rather than created, and a register arriving
after its scope closed is refused with err "connection closed".

`maxLiveModels` and `maxInFlightExecutes` were both check-then-act with
authorize/authenticate/create sitting in the gap, so every thread in
the pool could observe the same under-limit value and proceed —
overshooting by the pool's width, which is the burst the limits exist
to prevent. The live-model cap is now re-tested inside the same lock
that inserts; the in-flight cap reserves its slot with a
compare-exchange at the point the slot is genuinely taken. The cheap
early checks stay as advisory load-shedding.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
Both properties are load-bearing, because a sink is host code invoked
from the framework's hot paths:

- Under the lock, a sink that emitted a metric of its own — or
  reinstalled itself — deadlocked on a non-recursive std::mutex.
- Bare, a throwing sink escaped into the framework.
  `LocalBackend::execute` brackets each dispatch with beginSpan /
  emitMetric / endSpan and settles the caller's Completion *after*
  them, so an exception out of instrumentation skipped
  setValue/setException entirely and was swallowed by StrandExecutor's
  catch-and-log — leaving that Completion unsettled forever. A hung
  caller, caused by a bug in a metrics callback.

Sinks are now held behind a shared_ptr so an emitter takes a
refcount copy under the lock, releases it, and calls outside, inside
catch(...). Fixed at the source rather than at the one call site, so
every emitter is covered.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
An over-size frame was rejected with a zeroed callId. That is worse
than unhelpful: 0 is the client's synchronous-reply discriminator, so
the error resumed whatever register/deregister happened to be parked
and handed it another call's reply, while the execute it was meant for
never resolved. The id is now recovered by a bounded prefix scan, so
the frame still never gets decoded.

An undecodable message with no sync waiter was also dropped silently,
leaving that execute's Completion unsettled forever — neither .then()
nor .onError() ever firing. Every message here is required to be one
envelope, so an undecodable one means the peer's framing can no longer
be trusted: pending calls now fail rather than wait on a stream that
may never produce a matching reply. A spurious error is recoverable; a
permanent hang is not.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
`entries()` tolerated a truncated trailing line but never removed it,
and the file is opened "a" — so the next append() started writing at
the exact byte the truncated JSON stopped at, with no separating
newline. The two merged into one line that swallowed the new entry, and
a further append pushed that merged line out of trailing position, at
which point entries() threw. Since the constructor calls entries(), the
journal became permanently unopenable. Now truncated to the last
newline on open, which by construction can only discard an incomplete
record.

append() also discarded fwrite's result and flush() discarded
fflush/fsync's, while IActionLog::flush() returns void — so
`OutboxRelay::relay()` called markRelayed() straight afterwards,
recording rows as relayed in the model's own store while nothing
reached the durable sink. Both now throw, which aborts relay() before
the batch is marked and leaves it retryable. Idempotency keys are only
remembered as seen once a flush confirms they reached disk, so a failed
write is not deduplicated away on retry.

rotate() left a null handle when its reopen failed, contradicting the
destructor's own comment; every entry point now reports that instead of
dereferencing it.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
`load()` derives `_nextId` from the ids it reads and `compact()` writes
only surviving "put" lines, dropping every tombstone — so the mark
regressed to the highest *surviving* id from the second restart onward.
Enqueue 1 and 2, markDone(2), restart (compacts to just id 1), restart
again, and the next enqueue() reissued id 2: the id of a completed,
acknowledged item, breaking the "new ids never collide with an old
tombstone" invariant this class documents.

The mark is now carried across each rewrite as a trailing "done" record
for itself, emitted only when it exceeds every surviving id so it can
never delete a row a "put" above just restored. No reader change is
needed: load() already raises the mark for every id it sees, and
erasing an absent id is a no-op.

Also stops swallowing I/O failures — every mutation is documented as a
committed transaction by the time it returns — and fixes two unchecked
sqlite3_step calls: drain() treated an I/O error as "no more rows" and
returned a silently truncated set the caller takes for the complete
pending list, and a failed dedup lookup returned id 0, which matches
nothing, so markDone(0) stranded the item forever.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
`equals(&A::code, "URGENT")` stored its argument as a std::string, which
quietly bounded the documented `static constexpr auto formRules =
ruleList(...)` form to whatever fits the standard library's small-string
buffer — 15 characters on libstdc++. One more and the string allocates,
so the node stops being a constant expression and the declaration fails
with "refers to a result of operator new". The limit was invisible in
the source: the same code compiled or did not depending only on how long
the literal was, and on which standard library was in use.

Captured inline instead, aliasing the project's existing shared
`morph::detail::FixedString` rather than adding a fourth copy of it.
That type's constructor relaxes from consteval to constexpr so a
function parameter of type `const char (&)[N]` can be captured — as an
NTTP it is still only ever evaluated at compile time. Serialisation is
unchanged: emitNode() emits the same JSON string either way.

The existing test only built the node as a function-local `auto const`,
which never exercised constant evaluation; the new ones declare at
namespace scope, where it actually happens.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
`unsubscribe()` removes the sink but cannot recall a callback already
copied out of the handler's map, so a step re-fired just before
advance() can still land afterwards. `_currentReady` is not keyed by
step, so such a late reply marked the *new* step ready before anything
was entered into it — letting the next advance() skip a step outright —
and overwrote `_resolvedValues` with a result advance() never saw. A
late failure symmetrically cleared readiness for a step that had
legitimately completed.

Callbacks now carry the step index they were installed for and compare
it against `_activeStep` under the same mutex. A late error is still
reported to the host — the action did fail — it just no longer mutates
state belonging to another step.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
CollectionView keeps one modalForm and one detailForm for the whole
collection, and prefill() only writes the fields v-rowAction names in
`bind`. Everything else kept whatever the user left there while editing
the *previous* row — and because DynamicForm auto-submits as soon as the
form is `ready`, merely opening the next row fired the row action with
that row's key and the previous row's values. A silent write of data the
user neither entered nor saw. prefill() now resets first.

Separately, the tabbed layout drives its Repeater off
`sections[currentTab].fields`, so switching tabs destroys and rebuilds
every field delegate — and a control's text flows only outward, via
onTextChanged into fieldValues, never back. Returning to a tab showed
empty controls while revalidate() went on auto-submitting the retained
values: the form sent data the user could not see. Text-bearing controls
now re-seed from fieldValues on creation.

Both paths run under a `programmaticEdit` counter that suppresses
auto-submit, so repopulating a form never fires an action by itself.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
A real locale's separator is not always one byte: fr-FR groups with
U+202F (3 bytes in UTF-8) and several locales use U+00A0 (2 bytes).
Typed as `char`, neither could be expressed at all — a caller could only
pass some single byte that never matched, so a perfectly valid
"1 050,25" typed by a French user normalised to nullopt and the control
reported it malformed.

Separators are now matched as whole strings, before the per-byte digit
scan, so a multi-byte one works and its continuation bytes are not
mistaken for stray non-digit characters. An empty view means "this
locale has no such separator", the role '\0' used to play.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
Three gates that could not fail:

- The fuzz replay globbed `findings/*/*.txt`, but both committed
  reproducers are `.bin`. The replay ran only the seeds and never the
  crash inputs, so reintroducing the skip_ws heap overflow would have
  left the very test that exists to catch it green. Globs are now
  extension-agnostic — a fuzzer input is an arbitrary byte string — and
  an empty glob is a configure-time error, since a replay with no FILE
  arguments is an unbounded fuzzing run, not a regression check.

- The deprecation-marker linter was line-scoped, so it saw neither the
  bare `[[deprecated]]` form nor any message wrapped across lines —
  exactly what VERSIONING.md forbids. Rewritten to scan whole files,
  and given fixtures plus a self-test, because a lint gate nobody tests
  reports green whether or not it still detects anything.

- examples/forms/test_repl.sh asserted the same thing twice with two
  identical `grep -q` calls; the second could not fail, and the
  converted-units submit could have vanished entirely without anyone
  noticing. Now counts the matching lines, which is the property the
  two inputs actually establish.

Also lets MORPH_REQUIRE_VETTED_HMAC compose with MORPH_BUILD_TESTS. The
guard is a deployment switch aimed at application call sites; morph's
own suite deliberately exercises the reference hmacSha256 default, so
the inherited definition is undone for that target alone. Configuring
the documented option no longer requires switching tests off — which is
what kept the option itself untested.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
No job enabled any MORPH_BUILD_* option, so several thousand lines —
morph::net, the Qt/QML forms renderer, the SQLite queue, the fuzz
harnesses, the vetted-HMAC adapters, the soak/bench targets — were
never compiled here, let alone tested. That is how a documented build
that cannot configure and a replay test matching the wrong file
extension both reached master green, and how morph::net accumulated
GCC -Werror violations without anyone noticing.

- New linux-all-features job (GCC + Clang) turns every option on at
  once, which also proves they compose. Clang additionally builds the
  libFuzzer harnesses and asserts afterwards that the replay actually
  referenced each committed reproducer — a guard that never fires is
  indistinguishable from one that works.
- The sanitizer/coverage matrix now includes morph::net and the SQLite
  queue: raw sockets, an I/O thread, a hand-rolled frame reader and a C
  API are what ASan/TSan/UBSan are for, and all of it was being skipped.
- clang-tidy configures the full feature set so its compile database
  covers that code. It only ever analyses changed lines, so this buys a
  wider build, not a wider lint.
- Valgrind covers the net and SQLite suites too, and fails loudly if a
  suite it expects was not built.
- The deprecation lint runs its own self-test first.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
- SQLite3::SQLite3 was unresolvable at generate time on the ubuntu-24.04
  runner, which reports "Found SQLite3: /usr/include" and then fails the
  link interface of morph_offline_sqlite. Locally the same module
  reports the library path and creates the target, so the imported
  target is now synthesised from the variables FindSQLite3 does set when
  it is missing, and promoted to GLOBAL either way.

- MSVC rejects the   /   universal-character-names in the new
  locale test's narrow literals, because code page 1252 cannot represent
  them (C4566, fatal under warnings-as-errors). Spelled as explicit
  UTF-8 bytes instead, which is what those tests are actually about.

Both were only reachable once CI started building the opt-in features
and the sanitizer matrix started enabling the SQLite queue.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
MORPH_BUILD_FORMS_QML needs Qt 6.5 or newer — qt_standard_project_setup's
REQUIRES keyword and QQmlApplicationEngine::loadFromModule both arrive
there — but ubuntu-24.04 still ships 6.4.2, so the distro packages could
never build it. Enabling the option in CI surfaced that as a
missing-member compile error deep in the demo.

The two jobs that build the renderer now install Qt from aqt instead of
apt, and CMakeLists.txt states the 6.5 floor so a too-old Qt fails with
one clear configure-time message rather than that compile error. The
existing linux-qt job keeps using the distro Qt: it only builds the
WebSocket transport, which 6.4 handles fine.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
@Yaraslaut
Yaraslaut merged commit d2cb3ae into master Jul 27, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant