Skip to content

core+qt: public seams for the four detail:: reach-ins the ladder testkit needed - #79

Merged
Yaraslaut merged 7 commits into
masterfrom
issue-55-testkit
Aug 13, 2026
Merged

core+qt: public seams for the four detail:: reach-ins the ladder testkit needed#79
Yaraslaut merged 7 commits into
masterfrom
issue-55-testkit

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Summary

Closes #55.

Four use cases previously required reaching into morph detail:: namespaces; each now has a public seam:

  1. Settleable Completion<T> promiseCompletion<T>::makeSettleable(IExecutor*) returns a Completion<T>/Promise pair a test can resolve on demand, without a full Bridge/IBackend round trip. morph::async::detail::CompletionState<T> no longer needs to appear in caller code.
  2. StrandExecutor/ModelId interleaving harness — added morph::testing::StepExecutor (queue-and-single-step IExecutor) demonstrating deterministic hand-stepped interleaving against a real RemoteServer, using zero detail:: symbols.
  3. HandlerBinding::registered()/isBound() — already satisfied by No "registration settled" seam: dispatch issued right after connect fails "handler not bound" #60 (in the async-registration PR, core+qt: complete the async model-registration lifecycle (queueing, settled seam, callId hardening, Sharing-aware dispatch, async promote, connection scope) #71): Bridge/BridgeHandler::isBound()/whenBound(). Not duplicated here.
  4. QtWebSocketBackend::Config constructor overload — two new delegating overloads that default dispatcher/registry internally, so a caller who only wants Config::asyncRegistrationEnabled doesn't have to spell out morph::model::detail::defaultDispatcher()/defaultRegistry().

⚠️ Depends on #70 and #71

This branch merges issue-cluster-b-completion-bridge (#70, for Completion<T>'s post-#59 composing shape) and issue-cluster-g-async-registration (#71, for the post-#60 BridgeHandler shape) into itself, since seams #1 and #3 needed to build against their latest shape rather than stale master. Merge #70 and #71 first, then rebase this branch (or re-target it) to drop the merge commits before landing, to avoid duplicate history in the final diff. Opening as a draft for that reason.

Review

Ran contour-workflows:cpp-review against the branch diff (real skill invocation). Findings applied in the follow-up review: commit:

Test plan

morph_tests.exe: 8563 assertions / 874 cases, all passing. morph_qt_tests.exe: 447 assertions / 62 cases, all passing.

🤖 Generated with Claude Code

Yaraslau Tamashevich and others added 4 commits August 13, 2026 10:53
…kit needed

Closes #55: each of the four use cases the issue names now has a public seam
instead of requiring test code to name a `detail::` type.

1. `morph::async::detail::CompletionState<T>` -> `Completion<T>::makeSettleable(execPtr)`.
   A new static factory returns a `{Completion<T>, Completion<T>::Promise}`
   pair sharing one freshly allocated state: the `Completion<T>` is exactly
   what `then()`/`onError()` observe, and the paired `Promise` exposes
   `resolve()`/`reject()` to settle it on demand -- standing in for a full
   `Bridge`/`IBackend` round trip in a test -- without ever naming
   `morph::async::detail::CompletionState<T>`. `Promise`'s constructor is
   private and `friend`ed only to `Completion<T>`. `resolve()`/`reject()` are
   no-ops on an already-settled state or a moved-from `Promise`, mirroring
   `Completion<T>::then()`/`onError()`'s existing null-state no-op.

2. `morph::exec::detail::StrandExecutor`/`ModelId` -> a documented public
   interleaving-test harness built from existing public API, no new library
   surface needed. `RemoteServer` funnels every task it ever dispatches --
   both the top-level `handle()` post and its internal `StrandExecutor`'s
   per-model dispatch -- through the single `IExecutor` it was constructed
   with, and its wire replies already carry model identity as a plain
   `uint64_t` (`wire::Envelope::modelId`), never `ModelId`. Added
   `morph::testing::StepExecutor` to `tests/test_support.hpp` (a queue-and
   single-step `IExecutor`: `runOne()`/`runAll()`/`pending()`) and a pair of
   tests demonstrating a fully deterministic, hand-stepped interleaving
   harness against a real `RemoteServer` -- same-model ordering preserved,
   different-model work interleaved on the test's own schedule -- using only
   public vocabulary. Documented in docs/spec/core/executor.md next to
   `StrandExecutor`'s own section.

3. `morph::bridge::detail::HandlerBinding` -> already closed by #60's
   `Bridge::isBound()`/`whenBound()` and `BridgeHandler::isBound()`/
   `whenBound()` (merged in from issue-cluster-g-async-registration, not yet
   on master at the time of this branch). No new code needed for this seam;
   verified the existing predicate/awaitable pair covers the "observe whether
   an async registration has completed" use case without reaching into
   `HandlerBinding`'s internal `currentId` field.

4. `morph::model::detail::defaultDispatcher()`/`defaultRegistry()` ->
   two new `QtWebSocketBackend` constructor overloads, `(serverUrl, tls, cfg)`
   and `(serverUrl, cfg)`, that delegate to the existing constructor with
   dispatcher/registry defaulted internally. `QtWebSocketBackend` never
   actually uses those two parameters (model construction is delegated to the
   server), so a caller who only wants to set `cfg` (e.g.
   `Config::asyncRegistrationEnabled`) no longer has to spell out
   `morph::model::detail::defaultDispatcher()`/`defaultRegistry()` just to
   reach the parameters positioned after them.

Built with -DMORPH_BUILD_QT=ON; full suite green (morph_tests: 8563 assertions
/ 874 cases, morph_qt_tests: 447 assertions / 62 cases).

Files: include/morph/core/completion.hpp, include/morph/qt/qt_websocket_backend.hpp,
tests/test_support.hpp, tests/test_completion_promise.cpp,
tests/test_remote_step_interleaving.cpp, tests/qt/test_qt_websocket.cpp,
tests/CMakeLists.txt, docs/spec/core/completion.md, docs/spec/core/executor.md,
docs/spec/core/backend.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- tests/CMakeLists.txt: register test_bridge_pending_calls.cpp, which
  was added in c76b849 but never wired into the morph_tests target and
  so never compiled or ran (a real cpp-review Blocker: a test's absence
  from the build silently drops the coverage it claims to give).
- include/morph/qt/qt_websocket_backend.hpp: fix registerModelAsync's
  doc comment for the never-connected-then-destroyed case. It claimed
  a queued registration is dropped on destruction "without invoking
  either callback", but ~QtWebSocketBackend calls cancelPending(),
  which does invoke onError for every queued entry (by design, per its
  own comment) so the caller is never left waiting forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ehavior, not history

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tead of hanging

An unbounded 'while (runOne())' loop has no way to distinguish a
legitimately-draining queue from a task that keeps re-posting more work
to itself -- a strand bug, or a harness misuse, would spin runAll()
forever with no assertion failure and no compile-time signal, only a
hung test process indistinguishable from a CI timeout.

runAll() now takes a maxSteps bound (generous default: 10,000) and
throws std::runtime_error if it's reached, naming runOne() as the way
to step through and find the runaway task. Adds a regression test with
a small bound proving a self-re-posting task throws immediately rather
than hanging.

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

The other new constructor overload, (serverUrl, cfg), already had a
dedicated test; this three-argument one -- letting a caller pass a tls
configuration without naming the dispatcher/registry pair -- had none.
Every existing call site in this file uses either the full four-argument
constructor with explicit defaultDispatcher()/defaultRegistry(), or the
bare (url) shorthand, or the (url, cfg) overload -- never (url, tls, cfg)
directly.

Mirrors the existing Config-only overload test: connects, registers a
handler asynchronously, executes an action, confirms the round trip
works -- proving the overload resolves and delegates correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Yaraslaut
Yaraslaut marked this pull request as ready for review August 13, 2026 08:03
Yaraslau Tamashevich and others added 2 commits August 13, 2026 11:18
…y, not notifyCount

notifyCount is the wrong signal: OrderModel::onBackendChanged() increments
it BEFORE draining the queue, so polling on notifyCount reaching 1 only
proves the call started, not that the drain finished. This raced the
drain itself and reproduced on CI as both a plain assertion failure
(queue.drain().empty() intermittently still false) and, once, a genuine
UBSan misaligned-member-call report -- calling execute() concurrently
with a model instance mid-teardown during the same race window.

InMemoryOfflineQueue::drain() is a thread-safe, non-destructive snapshot
read (per its own doc comment), so polling it directly is safe and
becomes empty at the exact moment every item is drained and marked done
-- the actual signal these tests need, with no race window. Verified 20x
back-to-back locally with no failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ion to avoid an ODR collision

Root cause of the real CI failure: this file (added by this PR) declared
its own file-scope OrderModel/OrderAction with external linkage --
identical simple names to test_conflict_resolution.cpp's own, entirely
unrelated, pre-existing OrderModel (which has onBackendChanged(),
notifyCount, offline-queue draining, none of which this file's stub
type has). Two external-linkage types with the same name and different
definitions is a One-Definition-Rule violation the linker does not
diagnose; which definition ends up linked into which translation unit
is compiler/link-order dependent.

Confirmed via targeted tracing: in the affected builds, LocalBackend's
_changeAware set was empty after registering a model built from
test_conflict_resolution.cpp's own modelFactory -- i.e. the linker had
resolved BackendChangedNotifiable<OrderModel> using this file's bare
stub definition instead of the real one, so notifyBackendChanged()
never posted to onBackendChanged() at all and the offline queue never
drained. Reproduced deterministically (100% of runs, not flaky) on
Linux/clang-ubsan and on real CI across nearly every Linux job; verified
absent on 10/10 runs after this rename, both under WSL/clang-ubsan and
matching CI's own per-test-case invocation pattern.

Renamed to StepILOrderModel/StepILOrderAction (matching this file's own
StepIL_* wire-typeId convention already in use), eliminating the
collision without touching test_conflict_resolution.cpp at all.

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

codecov Bot commented Aug 13, 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 merged commit 69e303d into master Aug 13, 2026
39 of 41 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.

Ladder testkit reaches into four morph detail:: namespaces with no public seam

1 participant