qt: move QtWebSocketBackend onto the structural registration surface - #585
Conversation
…ions Both entries drifted once the new bindModel/promoteModel machinery was inserted above them: - B6 (`_executeDeadline.count() > 0 && _timeoutScheduler`) moved from line 1453 to 1498, text unchanged. - B11 (`deadlineHandle && schedulerRef`) moved from 1589 to 1610, but that source line is not unique -- it also appears, unrelated, in the .then()/.onError() continuations further down (1634, 1723, present on master too). Repointed to 1610, the specific exception-path occurrence B11's reasoning actually describes, and said so in the entry so the ambiguity doesn't recur silently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…ions Both entries drifted once the new bindModel/promoteModel machinery was inserted above them: - B6 (`_executeDeadline.count() > 0 && _timeoutScheduler`) moved from line 1453 to 1498, text unchanged. - B11 (`deadlineHandle && schedulerRef`) moved from 1589 to 1610, but that source line is not unique -- it also appears, unrelated, in the .then()/.onError() continuations further down (1634, 1723, present on master too). Repointed to 1610, the specific exception-path occurrence B11's reasoning actually describes, and said so in the entry so the ambiguity doesn't recur silently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
05a976d to
75a4865
Compare
75a4865 to
a08db4f
Compare
…ions Both entries drifted once the new bindModel/promoteModel machinery was inserted above them: - B6 (`_executeDeadline.count() > 0 && _timeoutScheduler`) moved from line 1453 to 1498, text unchanged. - B11 (`deadlineHandle && schedulerRef`) moved from 1589 to 1610, but that source line is not unique -- it also appears, unrelated, in the .then()/.onError() continuations further down (1634, 1723, present on master too). Repointed to 1610, the specific exception-path occurrence B11's reasoning actually describes, and said so in the entry so the ambiguity doesn't recur silently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Heads-up for this PR's eventual rebase: PR #595 (issue #587,
Why I let it through rather than making it wait: this PR is blocked on #593, a design decision at the #567/#571 level with no timetable. Holding The expected overlap is small and near This branch already reconciled |
…ions Both entries drifted once the new bindModel/promoteModel machinery was inserted above them: - B6 (`_executeDeadline.count() > 0 && _timeoutScheduler`) moved from line 1453 to 1498, text unchanged. - B11 (`deadlineHandle && schedulerRef`) moved from 1589 to 1610, but that source line is not unique -- it also appears, unrelated, in the .then()/.onError() continuations further down (1634, 1723, present on master too). Repointed to 1610, the specific exception-path occurrence B11's reasoning actually describes, and said so in the entry so the ambiguity doesn't recur silently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
a08db4f to
49bd5b8
Compare
Merge ordering, decided by the runner: #585 first, then #614These two PRs interact, and the interaction is measured rather than predicted. #614 adds a per-PR gate that resolves Measured against #585's head The Why this order:
So: land #585 when green. Then rebase #614, re-run its gate ( This is the gate working as designed, and it is the argument #614's PR body makes for the resolver approach over hand-maintained coordinates — recorded here so whoever runs the next landing sweep does not have to re-derive it. |
#616) (#617) All twelve test presets set `stopOnFailure: true`, so `ctest` abandoned the run at the first failure on every CI leg. One broken test and thirty broken tests therefore produced the same log, and every diagnosis made from that log was made on partial information. That cost is not hypothetical: CI on #585's head was read as "all six failing jobs fail on one test", and the singularity was treated as a property of the defect. Six tests were failing; `ctest` stopped at the first, and it took a full local build by someone else to establish that. Decision, per preset: **removed on all twelve.** Each ctest case is its own process here (`catch_discover_tests`), so no leg's later tests are invalidated by an earlier failure -- not the sanitizer legs, where a diagnostic aborts only its own process, and not `clang-coverage`, where stopping early loses profile data rather than protecting it. The "it saves time" argument does not survive the matrix either: the leg that already reports in full is Valgrind, the slowest one there is. A leg that ever does earn the setting can set it on its own preset, with its reason in its own `description`. The twelve presets now inherit a hidden `base-test` preset that carries `output`/`execution` once, so the decision lives in exactly one place with the reasoning next to it, instead of twelve copies of a setting nobody recorded a reason for. `ctest` has no `--no-stop-on-failure`, so keeping it in the preset and overriding it per invocation in CI is not available -- the preset is the only place the choice can be made. No workflow change is needed: every leg runs its suite through `ctest --preset ...`. Measured, gcc-debug on this revision, with two Catch2 cases in tests/test_rational.cpp deliberately broken (reverted before this commit) and the run filtered to `-R "^Rational::"`: # before, stopOnFailure: true 2/46 Test #597: Rational::Comparison ......***Failed 0.00 sec 50% tests passed, 1 tests failed out of 2 The following tests FAILED: 597 - Rational::Comparison (Failed) # after 46/46 Test #641: Rational::toDouble::PrecisionRoundtrip ... Passed 0.00 sec 96% tests passed, 2 tests failed out of 46 The following tests FAILED: 597 - Rational::Comparison (Failed) 599 - Rational::Constants (Failed) Both runs exit 8. Restoring `stopOnFailure` on the inheriting `gcc-debug` preset alone reproduces the one-line report, which is what shows the setting and not something else is responsible. Inheritance was checked rather than assumed. `noTestsAction: error` still reaches each leg through `base-test`: `ctest --preset gcc-debug -R zzz_no_such_test` exits 8 where the same filter without the preset exits 0. `outputOnFailure` likewise -- both failing cases printed their Catch2 bodies in the "after" run above. And CMake merges `execution` key by key across `inherits`: the child that set only `stopOnFailure` still errored on no tests. Full suite green on the new presets: 100% tests passed out of 1562 (gcc-debug, GNU 16.2.1). Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Bridge` is about to reach `IBackend::bindModel`/`promoteModel`, which take the executor their continuation is delivered on. `Bridge` owns no event loop, so the only executor it can honestly name is the one that reproduces what the four `*Async` verbs did: run the continuation on whichever thread the backend settled the reply on. That was previously a rule stated in a doc comment and checkable by nothing. `exec::detail::InlineExecutor` makes it a value a call site produces, which is the whole of what morph#567 moved -- not who is safe, but who decides. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
…blocking verb Each of `registerHandlerImpl`, `attachHandlerAsync`, `ensureBoundAsync` and `assignHandlerPrimary` offered the backend a legacy `*Async` verb and, when it answered `false`, ran the synchronous verb inline. The second path is now `IBackend::bindModel`/`promoteModel` instead, so the path *count* is unchanged -- morph#571 deletes the first branch and leaves one. This is what makes morph#568 possible at all: `Bridge` is the only framework caller of the four verbs, so a backend that stops overriding them would otherwise be routed straight back into the blocking call the verbs existed to avoid. Behaviour is preserved deliberately, not incidentally: - The executor named is `inlineExecutor()`, so continuations still run on the thread the backend settles on. A blocking backend therefore settles inside the dispatch frame with `_attachMtx` held -- exactly what `AsyncDispatchHandoff` already existed for, so both branches park and the dispatching frame publishes under the lock it owns and reports once it is gone. - Failures carry the original `exception_ptr` to `onDone` rather than a `runtime_error` rebuilt from `what()`, which is what the synchronous fallback they replace did and what the legacy string channel could not do. - `registerHandlerImpl` parks a rejection that arrives inside its own frame and rethrows it: a `BridgeHandler` constructor that could not bind must not return as if it had. One named behaviour change: `assignHandlerPrimary`'s blocking branch used to let `assignPrimary` throw out of a `Completion` handler, where `CompletionState` swallowed and logged it. It now logs directly, because `promoteModel` reports through its `Completion` by contract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
`QtWebSocketBackend` was the only backend in the tree overriding any of the four `*Async` verbs, so the entire prose threading contract on `IBackend` existed for exactly one class and the WASM special case it described lived here. It now overrides none of them, and implements `bindModel`/`promoteModel` directly -- not through `SynchronousBackendAdapter`, which moves a blocking call to another thread and a WASM main thread has no other thread to move it to. `Config::asyncRegistrationEnabled` stays, with its meaning narrowed to the one thing it actually decides: whether the transport blocks. Unset, `bindModel` defers to `IBackend`'s blocking default, which is the desktop behaviour every existing embedder relies on. It is no longer an opt-in to a second set of interface verbs, because the continuation now exists on both paths. Three duplications went with the verbs: - One send path (`sendControl`), so the "encode before recording the pending entry" invariant and morph#495's `env.session` stamp are stated once rather than four times. - One pending map: `register`, shared `register`, `attach` and `assign` replies were always matched identically, and the split had nothing left to represent. - `Completion<ModelId>::Promise` in place of two `std::function`s per entry, so a dropped socket now rejects with the `DisconnectedError` itself instead of a `runtime_error` rebuilt from its message -- the string channel could not carry a type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
…ath with it morph#567's "natively non-blocking backend" case exercised a test double settling its own promise, which pins the shape an implementor needs rather than what a real transport does. `QtWebSocketBackend` is the first real implementor, so this is the first test that the guarantee survives one. "bindModel delivers its continuation on the caller's executor, not the thread it settles on" uses an executor that counts posts without running them: the test pumps only the Qt loop until the reply lands and asserts the continuation has *not* run, then drains and asserts it has. Building the `Completion` against an inline executor instead of `cbExec` fails it (measured). The four direct `*Async` tests are rewritten against `bindModel` -- keyed register-or-attach, re-point, degrade-to-private, reject on a dead socket -- and `promoteModel` gains direct coverage including its documented no-op case. The morph#495 session-stamping test drives `bindModel`, where `sendControl` is now the single place that gap could reappear. `ThrowingSyncRegisterSharedBackend` now throws from `registerModelWithContext` too. `ensureBoundAsync` asks for an anonymous instance, and `BindRequest` names that shape (`primary` empty, `current` zero) as `registerModelWithContext` -- which `IBackend::registerModelShared` documents an empty primary as degrading to, and which every backend in the tree implements as its first statement. A double that threw only from `registerModelShared` would have stopped failing and the test would have passed by registering successfully. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
`backend.md` gains a migration-status table for the morph#522 set, marks the `*Async` sections as historical now that no backend overrides them, and describes `QtWebSocketBackend`'s native `bindModel`. Both specs are explicit that this does not close morph#486. `Bridge` names an inline executor because it owns no event loop, so the delivery thread is unchanged; what moved is the decision, from fifteen implementors to four call sites. Closing the window needs a `Bridge` executor bound to the thread that runs `~Bridge`, and nothing in the set currently owns that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
clang-tidy-diff flagged both under misc-const-correctness: neither url is reassigned after construction. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ions Both entries drifted once the new bindModel/promoteModel machinery was inserted above them: - B6 (`_executeDeadline.count() > 0 && _timeoutScheduler`) moved from line 1453 to 1498, text unchanged. - B11 (`deadlineHandle && schedulerRef`) moved from 1589 to 1610, but that source line is not unique -- it also appears, unrelated, in the .then()/.onError() continuations further down (1634, 1723, present on master too). Repointed to 1610, the specific exception-path occurrence B11's reasoning actually describes, and said so in the entry so the ambiguity doesn't recur silently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…593) `Bridge::registerHandlerImpl` has one call site for acquiring a model, and after morph#568 and morph#569 two shipped backends need opposite behaviour from it. Both return an unsettled `Completion` from `bindModel`, so from `Bridge`'s side they are indistinguishable: - `morph::net::SocketBackend` must be waited for. Its callers construct a `BridgeHandler` and use it on the next line, and `executeVia` fails fast on `currentId == 0`. - `QtWebSocketBackend` with `asyncRegistrationEnabled` must not be waited for. Its reply is delivered by the Qt event loop of the calling thread, so a wait is a deadlock -- on WASM, a page abort. That is the whole of morph#568. Adds `IBackend::bindWaitPolicy()`, returning `BindWait::kCallerMayBlock` by default and `kCallerMustNotBlock` for the two backends whose callers cannot usefully wait (`QtWebSocketBackend` under the flag, and `SynchronousBackendAdapter`, which exists precisely to move the blocking off the caller's thread and would deadlock against its own strand). Under `kCallerMayBlock`, `registerHandlerImpl` holds its inline window open until the completion settles (`detail::awaitHandoff`) and then publishes the outcome through the same code that already handles a backend settling inline -- so the two cases differ in how long the frame sits still, not in what the caller observes. This is not the `bool` morph#567 deliberately removed. That one chose *which verb to call*, so every call site carried two paths and a backend could be half-migrated. This one chooses nothing: one verb, called unconditionally, one continuation. It says only whether the thread that registered that continuation may stop and wait for it. Measured on this rebased tree (GCC 16.2.1, Debug, MORPH_BUILD_QT=ON MORPH_BUILD_NET=ON): - Before: 99% tests passed, 6 tests failed out of 1833 -- the six `tests/net/` cases morph#593 names. - After: 100% tests passed out of 1835. - Mutation, never wait (`false && ...` at the call site): 7 failures -- the same six, plus the new `kCallerMayBlock` case. - Mutation, always wait (`true || ...`): 6 failures -- the new `kCallerMustNotBlock` case, plus five `tests/qt/` cases that **time out** at 120 s rather than fail, which is the nested-`QEventLoop` deadlock in desktop clothing. Neither fixed setting of that call site is correct, which is the finding morph#593 recorded and this commit acts on. Also repoints `scripts/branch_partial_allowlist.json`'s two `bridge.hpp` line hints (1498 -> 1542, 1610 -> 1654) and the line numbers named inside the second entry's reasoning (1634/1723 -> 1678/1767), which this commit's insertions shifted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk
`QtWebSocketBackend` did not override `registerModelWithContext`, so it
inherited `IBackend`'s default, which drops the key -- while its own
`bindModel` non-blocking path carried it. The two disagreed, and which one
ran was decided by `Config::asyncRegistrationEnabled`, whose documentation
is entirely about blocking versus not blocking the Qt event loop.
This is not an audit entry missing a field.
`RemoteServer::attachLogIfConfigured` returns *before* consulting its
`LogProvider` when the envelope's `contextKey` is empty, so a privately
registered instance over this transport produced **no audit record at all**,
silently, while `SimulatedRemoteBackend` and `morph::net::SocketBackend`
(morph#587) produced one. Three call sites reached the dropping default: the
blocking `bindModel`'s empty-`primary`/zero-`current` branch (every private
registration with the flag at its `false` default),
`Bridge::switchBackend`'s re-registration after a reconnect (whatever the
flag was set to), and `registerModelShared`/`attachModel`'s empty-`primary`
degradation.
Fix is morph#587's two lines: override `registerModelWithContext` to build
`makeRegister(typeId, contextKey)`, and have `registerModel` forward to it
with an empty key, so one place builds this envelope rather than two.
morph#594's verification status was "inferred from reading the code, not
reproduced". It is now **reproduced**. The new case in
`tests/qt/test_qt_websocket.cpp` stands up a `QtWebSocketServer` over a
`RemoteServer` with a `LogProvider` installed and, before the fix, failed on
the two sections that send a key:
tests/qt/test_qt_websocket.cpp:2504: FAILED:
CHECK( requestedFor == std::vector<std::string>{"WsEchoModel:acct-594"} )
with expansion:
{ } == { "WsEchoModel:acct-594" }
tests/qt/test_qt_websocket.cpp:2523: FAILED:
CHECK( requestedFor == std::vector<std::string>{"WsEchoModel:acct-bind"} )
with expansion:
{ } == { "WsEchoModel:acct-bind" }
Per AGENTS.md's "would this still pass if the feature did nothing": the
assertion is on the provider, not on the registration. Registration
succeeded before the fix too -- all 15 assertions in the case passed except
those two.
The third section asserts the other direction: plain `registerModel` still
sends no key, so the provider is *not* consulted. `registerModel`'s
behaviour is unchanged.
Full suite after: 100% tests passed out of 1836 (GCC 16.2.1, Debug,
MORPH_BUILD_QT=ON MORPH_BUILD_NET=ON).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk
`check_branch_coverage.py`'s `resolve_allowlist_source_line()` failed the
`Linux / clang-coverage` leg on three entries in
`scripts/branch_partial_allowlist.json`:
error: include/morph/util/rational.hpp:1542 has moved to line 1498.
error: include/morph/core/backend.hpp:1230 has moved to line 1324.
error: include/morph/core/bridge.hpp:1498 has moved to line 1542.
Only one of the three is this branch's doing. Resolving each entry's
`source` text against both revisions:
rational.hpp hint 1542 -> master: 1498 this branch: 1498
bridge.hpp hint 1498 -> master: 1453 this branch: 1542
backend.hpp hint 1230 -> master: 1230 this branch: 1324
`rational.hpp` and `bridge.hpp` were **already stale on master** and this
branch does not touch `rational.hpp` at all; `backend.hpp` drifted by the
94 lines this branch adds. All three are repointed here because the leg
cannot go green otherwise, and every `source` text still matches uniquely,
so no disposition changed -- only the coordinates.
The pre-existing half is filed separately rather than folded in: the file
that `resolve_allowlist_source_line()` exists to harden is itself carrying
drift on master, which is the same rot class as morph#608.
Verified: all 22 entries in the allowlist now resolve to the line they
name on this branch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk
`scripts/check_mutation_survivors.py`, which landed on master in #614 after this branch was cut, resolves each triaged equivalent mutant's `source` text and fails when the `line` hint has drifted. Two entries name code this branch moves: include/morph/core/bridge.hpp:121 has moved to line 122 include/morph/core/backend.hpp:1228 has moved to line 1322 Both `source` texts still match uniquely, so neither disposition changed -- only the coordinates. `backend.hpp`'s 94-line delta is this branch's; the `bridge.hpp` one-line shift likewise. This is the gate working as designed, and was predicted before either branch was written: #614's PR body records that these two hints would go stale when this branch landed, and named the two files. Verified: `python3 scripts/check_mutation_survivors.py` reports `ok: 7 structured citation(s) ... resolve to the line they name`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk
4abeaed to
691174e
Compare
|
|
Closes #568, fixes #593, fixes #594. Step 2 of the five-step set re-scoped out of #522.
Status: unblocked. The regression this PR previously carried is fixed inside
it, because the fix is a change to the surface #568 moved onto and belongs
nowhere else.
Three commits, one per ticket:
qt: move QtWebSocketBackend onto the structural surface, natively(+ the three that go with it)core: let a backend say whether its caller may wait for a bindIBackend::bindWaitPolicy(), the one signal #567's surface leftBridge::registerHandlerImplwithout. Fixes the"handler not bound"regression this PR caused inmorph::net::SocketBackend.qt: carry contextKey on a blocking private registrationQtWebSocketBackendnow overridesregisterModelWithContext, so a privately registered instance is journalled. Reproduced first, then fixed.Rebased onto
a020e69c, not70792bee. The previous red was judged againsta base that has since moved on three counts that matter here: the gates changed
(
.github/workflows/ci.yml,cmake/CompileCache.cmake), files this PR toucheschanged (
include/morph/core/backend.hpp,scripts/branch_partial_allowlist.json,docs/spec/core/backend.md), and thefailing test's own file changed (
tests/net/test_socket_backend.cpp,include/morph/net/socket_backend.hpp). Everything measured below was measuredon the rebased tree. One conflict, in
docs/spec/core/backend.md'sIBackendtable: master's
registerModelWithContextrow (#587's, which namesSocketBackend) and this branch'sregisterModelAsyncrow (which records thatno backend overrides it since #568) — both kept, neither is about the other.
Three things fixed along the way, earlier in this PR's history: two
misc-const-correctnessclang-tidy findings on the new test file (const QUrl); a stale coverage-allowlist line hint inbridge.hpp(repointed anddisambiguated — it is one of three textually-identical
if (deadlineHandle && schedulerRef)guards, and the entry now names which one it covers and why theother two are a different, reachable arm); and #589, a pre-existing and
unrelated master
clang-tsantimeout inequation()'s stack-safety test, fixedseparately and merged rather than folded in here.
Three things were fixed along the way: two
misc-const-correctnessclang-tidyfindings on the new test file (
const QUrl); a stale coverage-allowlist linehint in
bridge.hppthat master's insertions had shifted (repointed anddisambiguated — it is one of three textually-identical
if (deadlineHandle && schedulerRef)guards, and the entry now names which one it covers and why theother two are a different, reachable arm); and #589, a pre-existing and
unrelated master
clang-tsantimeout inequation()'s stack-safety test, whichwas fixed separately and merged rather than folded in here.
Reconciliation with #586 (
SocketBackend, morph#569)#586 landed on master first and edits the same spec, so
docs/spec/core/backend.mdwas reconciled, not resolved by taking a side. Both descriptions survive
independently in the merged file:
docs/spec/core/backend.mdSocketBackend×119,bindModel×55QtWebSocketBackend×61,inlineExecutor×3QtWebSocketBackendimplements the surface natively and drops all four*Asyncoverrides"SocketBackendimplements the surface natively, keeping every legacy verb onsendSync"SocketBackend's full "native rather thanSynchronousBackendAdapter" rationale, intactThe backend/binding table at lines 564-566 now lists all three cases together
(
QtWebSocketBackendwith the flag unset → blocking/bound, with it set →native/unbound,
SocketBackend→ native/unbound). No conflict markers remainanywhere in the tree.
Everything below is written for someone picking this up cold.
State
It builds and the full local suite passes. Measured on the rebased tree at
the exact commit pushed (
49bd5b8b, working tree clean — what was built is whatwas pushed), GCC 16.2.1, Debug,
-DMORPH_BUILD_QT=ON -DMORPH_BUILD_NET=ON:cmake --build— clean, no warnings (this tree is-Werror, 63 warningflags,
strict=ON).QT_QPA_PLATFORM=offscreen ctest -j4→100% tests passed out of 1836(16 s).
clang-format --dry-run --Werror(v22.1.8) over every changed.hpp/.cpp→clean, no output.
clang-tidy-diff.pyovergit diff origin/master, clang-tidy 22.1.8 againsta
clang-debugcompile database configured withMORPH_BUILD_QT=ON MORPH_BUILD_NET=ON→ exit 0, no findings. It found one on the first run(
readability-redundant-member-initon the new test double'sstd::thread::id callerThread{}), which is fixed in the core: #567's registration surface gives Bridge no way to tell a natively non-blocking backend from a blocking one, and two shipped backends now need opposite behaviour at that one call site #593 commit.git diff --name-only origin/master HEAD | bash scripts/check_spec_sync.sh→Spec sync OK: 10 sub-domain(s) classified.bash scripts/check_spec_citations.sh→Prose lint OK.Both
MORPH_BUILD_QTandMORPH_BUILD_NETare ON, and that is the point.The earlier
1613-pass run in this PR's history hadMORPH_BUILD_NET=OFF, sotests/net/was never built, which is exactly how the regression was missed thefirst time.
Verified against the allowlist repoint, not assumed. The #593 commit's
insertions shifted
bridge.hppagain:1498 → 1542(
if (_executeDeadline.count() > 0 && _timeoutScheduler) {, still the onlyoccurrence of that text) and
1610 → 1654(thecatch-blockif (deadlineHandle && schedulerRef) {the disposition's own reasoningdescribes, with the other two now at 1678 and 1767, and the entry's prose
updated to say so).
Not run locally, so inferred from CI rather than measured here: the sanitizer
builds (ASan/UBSan/TSan), Doxygen, the WASM ladder/demo legs, and Windows.
tests/qt/is sanitizer-instrumented as of #576.#593 — the blocking regression, and its fix
What it was
registerHandlerImplprobesregisterModelAsyncfirst; with #568's overridesdeleted it returns
false, so control reaches this PR'sbindModelfallback.SocketBackend::bindModelis non-blocking (#586), soregisterHandlerImplreturned with
binding->currentId == 0, and the next line'sexecutehit thefail-fast guard:
Reproduced on the rebased tree before the fix —
99% tests passed, 6 tests failed out of 1833, the same six #593 names:The 1-versus-6 discrepancy, resolved rather than assumed
#593's body reports six failing tests; CI reported one, on every failing leg.
Both are right. Every test preset in
CMakePresets.jsonsetsstopOnFailure, andci.ymldrives every leg throughctest --preset …, so aleg with six failures stops at the first and reports it. The one failing leg
that does not use a preset —
Valgrind memcheck— reported five, read fromits log rather than from this PR's earlier summary of it:
So Valgrind's failure is the same functional regression and not a memcheck
finding — confirmed from the log, which the previous revision of this PR
asserted without having read it. Filed the preset behaviour as #616; it is
not this PR's to fix.
The fix
IBackend::bindWaitPolicy(), returning a two-valuedBindWait:kCallerMayBlockbindModel(settles inline), andSocketBackend(its I/O thread settles it)kCallerMustNotBlockQtWebSocketBackendwhenasyncRegistrationEnabledis set (the reply is delivered by the calling thread's own Qt event loop), andSynchronousBackendAdapter(it exists to move the blocking off the caller, and would deadlock against its own strand)registerHandlerImplthen holds its inline window open until the completionsettles (
detail::awaitHandoff,claimHandoff's blocking twin) and publishesthe outcome through the code that already handled a backend settling inline —
including the rethrow, so a failed registration still throws out of the
BridgeHandlerconstructor exactly as it always did. The two cases differ inhow long the frame sits still, not in what the caller observes.
Why this is not the
bool#567 deliberately removed. Thatboolchosewhich verb to call, so every call site carried two paths and a backend could be
half-migrated. This one chooses nothing: one verb, called unconditionally, one
continuation. It says only whether the thread that registered that continuation
may stop and wait for it.
backend.hpp's design note now says this in place,next to the point it qualifies.
Why not just update the six tests instead. That was #593's own stated
alternative, conditional on evidence that
SocketBackend's synchronousregistration contract was not load-bearing outside
tests/net/— evidencenobody has. Rather than acquire it, this keeps the contract:
registerHandlergoes on returning a bound handler for every backend that can honour it, and the
break is confined to the one deployment that opted into it by name.
Verification — both horns, measured on the fix
100% tests passed out of 1835false && …— never wait, i.e. the discriminator ripped outSocketBackendcases above, plus the newregisterHandler waits out a kCallerMayBlock backend's bindcasetrue ‖ …— always waitregisterHandler does not wait for a kCallerMustNotBlock backendcase, plus fivetests/qt/cases that time out at 120 s rather than fail — the nested-QEventLoopdeadlock, not a wrong answerNeither fixed setting of that call site is correct. That is #593's finding, and
it is what the discriminator exists for.
The two new tests
Both in
tests/test_backend_registration_surface.cpp, driving aBridgeover adouble whose bind reply arrives 200 ms later from a transport thread:
registerHandler waits out a kCallerMayBlock backend's bind and returns bound— asserts
isBoundwith no polling and no drain, plus that the value wasproduced on a thread
registerHandlerdoes not own.registerHandler does not wait for a kCallerMustNotBlock backend— assertsREQUIRE_FALSE(isBound)immediately after the call, then polls until it binds.The 200 ms delay is what makes each one fail under the opposite policy; neither
assertion's passing direction depends on its value (the waiting case waits
however long the reply takes; the non-waiting case polls).
#594 — contextKey on a blocking private registration
#594's verification status was "inferred from reading the code, not reproduced".
It is now reproduced. The new case in
tests/qt/test_qt_websocket.cppstandsup a
QtWebSocketServerover aRemoteServerwith aLogProviderinstalled,and before the fix — on a tree that already had #593's fix, so the mechanism was
not made moot by it — failed exactly where #594 predicted:
Per AGENTS.md's "would this still pass if the feature did nothing": the assertion
is on the provider, not on the registration. Registration succeeded before the
fix too — 13 of the case's 15 assertions passed. This matters because
RemoteServer::attachLogIfConfiguredreturns before consulting itsLogProvideron an emptycontextKey, so the symptom is not an audit recordmissing a field, it is no audit record at all.
Fix is #587's two lines: override
registerModelWithContextto buildmakeRegister(typeId, contextKey), and haveregisterModelforward to it withan empty key. A third section asserts the other direction — plain
registerModelstill sends no key, so the provider is not consulted.
Done
QtWebSocketBackendoverrides none of the four*Asyncverbs andimplements
bindModel/promoteModelnatively. Evidence:include/orsrc/overrides any of the four now.Bridge's four dispatch sites fall back tobindModel/promoteModelinsteadof to a blocking verb. This is not optional decoration:
Bridgeis the onlyframework caller of the four verbs, so without it a
QtWebSocketBackendthatstopped overriding them would be routed straight back into the nested
QEventLoopthe verbs existed to avoid.interface, proven end to end (see Tests below).
docs/spec/core/backend.mdanddocs/spec/concurrency_and_lifetimes.md.tests/test_async_registration.cpp's eleven doubles are untouched except one(see Traps), so core: remove IBackend's four async twins and retire the prose threading contract #571's scope is intact.
Tests, and their verification status
bindModel delivers its continuation on the caller's executor, not the thread it settles on(new)makeSettleable(&cbExec)withmakeSettleable(&inlineExecutor())→FAILED: REQUIRE(cbExec.posted.load() != 0).bindModel called before the socket connects queues and retries once connected firesbindModelto always take the blocking default →FAILED ... due to unexpected exception: register failed: disconnected.examples/common/testkit/test_wasm_registration_path_native.cpp, both casesregister failed: disconnected, the other hangs and dies onSIGTERM— the nested-QEventLoopdeadlock, which is the WASM page abort in desktop clothing.promoteModelcases*Asynctests ontobindModel. They pass; I did not watch each one fail on unfixed code. Treat them as regression coverage, not as proof of the new shape.The delivery-thread test is the one that matters: #567's equivalent case
exercised a test double settling its own promise, so it pinned the shape an
implementor needs. This is the first time the guarantee has been tested against
a real transport.
Decisions made
bindModelis implemented natively, not viaSynchronousBackendAdapter.The adapter moves a blocking call onto another thread; a WASM main thread has no
other thread to move it to. With
Config::asyncRegistrationEnabledset,bindModelbuilds the envelope the request's shape names, assigns acallId,sends, and returns an unsettled
CompletionthatonTextMessagesettles:primarycurrent0register(queued if the socket has not connected yet)deregisterofcurrent, thenregister0register+sharedattach, namingcurrentA keyed bind on a dead socket rejects rather than queueing: unlike a private
registration it may be a re-point, and replaying it against a re-established
connection would attach from a
currentthat connection never issued.Config::asyncRegistrationEnabledis kept, with its meaning narrowed. Unset(the desktop default)
bindModeldefers toIBackend::bindModel, which blocksin the nested
QEventLoop. Removing the flag would change desktop semantics —a
BridgeHandlerwould stop being usable on the line after its constructor — andbreak every desktop example and its tests. It is no longer an opt-in to a second
set of interface verbs; that is the part #568 asked to delete, and it is gone.
Is the WASM special case gone? In the interface, yes: no
*Asyncoverride,no
boolreturn, no fallback verb, onebindModelwhose non-blocking case isits ordinary shape. What remains is a
Configfield that chooses whether thetransport blocks, which is a deployment property rather than an interface
special case. If a reviewer disagrees and wants the flag gone too, that is a
behaviour change that belongs with #570 (which owns the example mains that set
it), not here.
Three duplications collapsed with the verbs: one send path (
sendControl,so the encode-before-record invariant and morph#495's
env.sessionstamp existonce instead of four times); one pending map (all four reply kinds were matched
identically); a
Completion<ModelId>::Promiseper entry instead of twostd::functions, so a dropped socket rejects with theDisconnectedErroritself rather than a
runtime_errorrebuilt from itswhat().Behaviour preservation in
Bridgewas deliberate at each site:inlineExecutor()→ continuations run where they ran before._attachMtxheld,which is exactly what
detail::AsyncDispatchHandoff/parkIfInFramealreadyexisted for, so both branches park and the dispatching frame publishes under
the lock it owns and reports once it is gone.
attachHandlerAsync/ensureBoundAsyncnow deliver the originalexception_ptrtoonDonerather than a stringified one — closer to thesynchronous fallback they replace, which propagated the original.
registerHandlerImplparks a rejection arriving inside its own frame andrethrows it, because a
BridgeHandlerconstructor that could not bindmust not return as if it had.
assignHandlerPrimary's blocking branch used tolet
assignPrimarythrow out of aCompletionhandler (whereCompletionStateswallowed and logged it). It now logs directly.tryRouteControlReplysettles outside_pendingMtx— it moves the promiseout of the map, releases the lock, then settles. With an inline executor,
settling under the lock would re-enter the backend under a lock the frame holds.
Not done
In order, for whoever continues:
Push and watch CI.Done — pushed and driven to the result in thestatus line at the top of this PR.
RunDone.--no-tests=errormentally over the renamed cases.Four
TEST_CASEtitles changed (registerModelAsync ...→bindModel ...).All still begin
morph::qt::QtWebSocketBackend:, so the leg's-R 'morph::qt::|qt_tls_example_runs'filter still matches — confirmed byrunning that exact filter on the rebased tree: 59/59, none skipped.
Decide whether to file the two core: give IBackend a registration surface whose threading contract is structural, not prose #567 surface gaps below as issues.Partly done. Gap 2 is now filed as core: give Bridge an executor bound to its own thread, so the threading guarantee is structural for Bridge-mediated calls too #588, triaged
rescope— itsproposed remedy contradicts
docs/spec/concurrency_and_lifetimes.md:205("teardown is order-independent, on any thread"), so it belongs to core: remove IBackend's four async twins and retire the prose threading contract #571
rather than here. Gap 1 and gap 3 remain notes for net: move SocketBackend onto the structural registration surface, or justify the adapter #569/core: remove IBackend's four async twins and retire the prose threading contract #571.
Configflag should survive — see "Decisions stillopen".
Un-draft and switchRefs #568toCloses #568once CI is green.Done — this PR is out of draft and says
Closes #568.Decisions still open
Should
Config::asyncRegistrationEnabledbe removed? I kept it. Arguments Iweighed: removing it would make
QtWebSocketBackendunconditionallynon-blocking, which is arguably the honest end state and would delete the last
thing that smells like a WASM special case; but it changes desktop registration
from synchronous to asynchronous, which every desktop example and a large number
of ladder tests depend on (a handler usable on the next line). Evidence I have:
the flag is read in exactly one place now (
src/qt/qt_websocket_backend.cpp:163),and its consumers are the example WASM mains, which are #570's scope. I
concluded that removing it is a behaviour decision for #570/#571 and out of
scope for step 2. Someone may reasonably disagree.
Should
Bridgegrow an executor of its own? Not attempted — see the gapssection. This is the difference between "the guarantee is structural" and "the
decision is structural", and only the second is true today.
Gaps in #567's surface — read this before #569, #570 and #571
1.
bindModelalone cannot replace a*Asyncverb; the caller has to movewith it. #567's surface deliberately removed the
boolthat meant "I have noasync path". That is the right design, but it has a consequence the ticket set
does not account for: there is no way for a caller to tell a natively
non-blocking backend from a blocking one, so a backend cannot be migrated
without its caller being migrated in the same change.
Bridgeis that callerfor all four verbs. #568 as written ("move
QtWebSocketBackend, do not touchanything else") is therefore not achievable as stated — the WASM path breaks the
moment the overrides are dropped. I migrated
Bridge's four dispatch sites,keeping the legacy branch first so the eleven test doubles in
tests/test_async_registration.cppstill exercise it and #571's scope is intact.#569 will hit exactly the same wall if it chooses the native route for
SocketBackend; if it chooses the adapter, it will not, because the adapter isreached through the same
bindModelBridgenow calls.2. The structural threading guarantee is not yet real, because
Bridgehas nothread to name.
Completiondelivers to the executor the caller passes. Thecaller here is
Bridge, which owns no event loop and no strand, so the onlyexecutor it can honestly name is
exec::detail::inlineExecutor()— "deliverwherever you settled", which is precisely what the prose contract already
required of backends. So #568 does not close morph#486 and this PR does not
claim it does. What changed is who decides: one value at four call sites
instead of an obligation on fifteen implementors. Turning that into an actual
guarantee needs
Bridgeto hold an executor bound to the thread that runs~Bridge, and no ticket in the #522 set owns that. If #571 intends to"retire the prose threading contract" rather than relocate it, this is the
missing piece. Both specs now say so explicitly rather than implying otherwise.
Filed as #588, and triaged
rescope: the remedy as written there (giveBridgean executor bound to the thread that runs~Bridge) contradictsdocs/spec/concurrency_and_lifetimes.md:205, which states that teardown isorder-independent and may run on any thread. The finding is real; the fix
belongs to #571's redesign, not to this PR. This PR therefore continues to
state that #568 does not close morph#486.
3.
BindRequestcannot expressregisterModelSharedwith an empty primary.Bridge::ensureBoundAsyncasks for an anonymous shared instance — it callsregisterModelShared(typeId, factory, {contextKey, primary = {}}). InBindRequestterms that isprimaryempty +currentzero, whichbindModelBlockingroutes toregisterModelWithContext. Different verb.Verified harmless:
IBackend::registerModelShareddocuments an empty primary asdegrading to
registerModelWithContext, and every backend in the treeimplements that degrade as its first statement (
LocalBackend,SimulatedRemoteBackendatremote.hpp:1874,SocketBackendatsocket_backend.hpp:219,QtWebSocketBackend). It cost me one test failureanyway — see Traps. Related and also verified harmless:
attachModel(..., current = 0)is not expressible either (it maps toregisterModelShared); server-side,register+sharedandattachwithmodelId == 0both reachacquireSharedInstance(..., ModelId{0})through thesame
authorizeRegistergate (remote.hpp:1067vs:1154). #571 should notassume the request-shape mapping is injective.
Traps
bindModelBlockingrouting change is silent. It cost me exactly onefailing test:
ensureBoundAsync's synchronous fallback surfaces a real registerModelShared throw through onDone (Task 15a finding B2). Its doubleoverrode only
registerModelSharedto throw, so after the routing change thecall reached
registerModelWithContext, succeeded, and the test passed byregistering rather than by surfacing a throw. I made the double throw from
both verbs and documented why in place. If you see another test go green for
no reason, check which verb it is actually reaching now.
cmake --buildoutput withgrep -vE "^-- |^\[[0-9]+/", which yields an empty file on success and on abuild that has not reached the failing target yet. Check the exit code.
QT_QPA_PLATFORM=offscreenat build time as well as testtime — Catch2's
catch_discover_tests()runs each Qt-linked binary onceduring the build to enumerate cases.
-DMORPH_BUILD_LADDER=ONtook ~40minutes on a machine shared with two other lanes' builds. Budget for it.
test_wasm_registration_path_native.cppcase blocks in a nestedQEventLoopforever; I only saw it because I ran it under
timeout. Always use one.tests/qtandtests/net_qt_interopare sanitizer-instrumented for the firsttime as of Four gates that could not fail: UBSan modes, sanitizer instrumentation, spec-sync, and the forms key vocabulary #576, and
-DAF_SANITIZER=<unknown>is now a configure-timeFATAL_ERROR. I did not run a sanitizer build, so I have no evidence eitherway about what they will find here. If instrumentation surfaces a pre-existing
defect, file it per AGENTS.md rather than folding the fix in.
Issues filed
Closed by this PR:
because the defect is in the surface qt: move QtWebSocketBackend onto the structural registration surface and delete the WASM special case #568 moved onto: no other PR owns that
call site.
QtWebSocketBackenddroppingcontextKey. Reproduced, then fixed.Filed in passing from this lane, not folded in:
Bridge::switchBackend's phase 1 andinstallReconnectHandler'scallback still call the blocking
registerModelWithContext/registerModelShareddirectly, with no structural path and nobindWaitPolicy()check. So akCallerMustNotBlockbackend is blocked thereanyway: a WASM client that never blocks on its first registration can still
block on a reconnect or a backend swap. Inferred from reading the code, not
reproduced — no WASM client was built and no reconnect deadlock observed.
Includes a second, smaller finding:
docs/spec/core/backend.md's migrationtable assigns the reconnect half to ladder: move the example GUIs and the WASM spike onto the structural registration surface #570, while ladder: move the example GUIs and the WASM spike onto the structural registration surface #570's own body scopes itself
to
examples/and never mentionsbridge.hpp.switchBackendis named byneither, and its atomicity (rollback keyed on a throw) is why it is not a
one-line substitution.
ctestpreset setsstopOnFailure, so a red leg reports onefailing test when six fail, and which one it reports is a
-jrace.Reproduced: same commit, 1 in CI versus 6 locally, with both outputs.
Still open from earlier revisions of this PR:
Bridgenames no executor bound to its own thread" (gap 2 above),triaged
rescopeto core: remove IBackend's four async twins and retire the prose threading contract #571. This PR continues to state that qt: move QtWebSocketBackend onto the structural registration surface and delete the WASM special case #568 does notclose morph#486.
Gaps 1 and 3 above remain written up here rather than filed: gap 1 is now closed
by #593's fix, and gap 3 is a footnote #571 needs when it touches the
request-shape mapping.
Nothing else was found in passing that needed a separate issue.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk