Bridge::registerHandlerImpl has exactly one call site for acquiring a model, and after morph#569 (#586) and morph#568 (#585) two shipped backends need opposite behaviour from it. #567's surface deliberately removed the only signal that could tell them apart, so the call site cannot satisfy both.
This was predicted as "Gap 1" in #585's PR body while the wall was still hypothetical. It is no longer hypothetical: it is now reproducible against a production backend, and it blocks #585.
The two requirements
| Backend |
What bindModel does |
What registerHandlerImpl must do |
Why |
morph::net::SocketBackend (native since #586) |
returns an unsettled Completion; the I/O thread settles it later |
must block until it settles |
its callers construct a BridgeHandler and use it on the next line; executeVia fails fast on currentId == 0 |
QtWebSocketBackend, asyncRegistrationEnabled = true |
returns an unsettled Completion |
must not block |
blocking here is the nested-QEventLoop deadlock that aborts the WASM page — the whole point of #568 |
From Bridge's side the two are indistinguishable: both are an override of bindModel that returns unsettled. include/morph/core/backend.hpp:487-493 rules out the discriminator by design:
- The continuation is not opt-in. There is no
bool saying "I have no async path, call the other one" — so no call site carries a second path, and a backend cannot be half migrated.
That bool was registerModelAsync's return value, and it was exactly this signal. #568 deletes the overrides that produced it.
Verification status
Reproduced, locally and in CI, on a08db4f9 (#585's head, rebased onto master 70792bee). GCC 16.2.1, Debug, -DMORPH_BUILD_QT=ON -DMORPH_BUILD_NET=ON. The mutation experiment below was run locally; I did not run it under a sanitizer, and I did not retrieve the CI logs for the clang-tsan and Valgrind memcheck legs (the run was still in progress), so my attribution of those two to this same cause is inferred from the leg configuration, not measured.
Reproduction, as shipped
tests/net/test_socket_backend.cpp — 5 consecutive local runs, all failing identically:
2/2 Test #1631: SocketBackend: action result delivered via then ...***Failed 2.03 sec
REQUIRE( result.load() == 99 )
with expansion:
-1 == 99
Six tests fail, all SocketBackend, none of which this branch's diff touches:
96% tests passed, 6 tests failed out of 142
1631 - SocketBackend: action result delivered via then (Failed)
1647 - SocketBackend: executeTimeout surfaces as backend::TimeoutError, not a generic runtime_error (Failed)
1652 - SocketBackend: many concurrent in-flight executes all resolve, matched by callId (Failed)
1660 - SocketBackend: server dropping mid-call resolves the pending completion with DisconnectedError (Failed)
1663 - SocketBackend: two backends share one server with isolated model state (Failed)
1804 - SocketBackend interop: connects to a QtWebSocketServer and completes an action (Failed)
On master the same leg is green and the same test takes 0.03 s, versus a 2.02 s spin here — it is not timing jitter, the completion never arrives at all.
Mechanism
registerHandlerImpl probes registerModelAsync first; with #568's overrides deleted it returns false, so control reaches the bindModel fallback. SocketBackend::bindModel is non-blocking, so registerHandlerImpl returns with binding->currentId == 0. The next line's execute then hits bridge.hpp:1463:
if (raw == 0U) {
typedState->setException(std::make_exception_ptr(std::runtime_error("handler not bound")));
return typed;
}
The test's .onError([](const std::exception_ptr&) {}) swallows that, result stays at its -1 initialiser, and spinUntil burns its full 200 × 10 ms budget. Nothing hangs; the registration simply had not completed.
Mutation experiment — both horns measured
Forcing the default blocking bindModel at that one call site, bypassing the virtual:
auto completion = backend->::morph::backend::detail::IBackend::bindModel(...);
flips which suite breaks, and breaks the one #568 exists to fix:
1566 - morph::qt::QtWebSocketBackend: (serverUrl, tls, cfg) constructor overload omits the dispatcher/registry pair (issue #55) (Failed)
1567 - morph::qt::QtWebSocketBackend: Config-only constructor overload omits the dispatcher/registry pair (issue #55) (Failed)
1571 - morph::qt::QtWebSocketBackend: bindModel (non-blocking via Config::asyncRegistrationEnabled) registers without blocking (Failed)
1572 - morph::qt::QtWebSocketBackend: bindModel called before the socket connects queues and retries once connected fires (Failed)
1577 - morph::qt::QtWebSocketBackend: bindModel's pending registration is cancelled when the connection drops before a reply arrives (Failed)
All six SocketBackend failures clear under that mutation, and five QtWebSocketBackend ones appear. Neither setting of the one call site is correct, which is the finding: this is not a bug in either backend, it is a missing distinction in the surface between them.
Why it is not fixable inside #568
Blast radius beyond the tests
This is not test-only. Any SocketBackend consumer that constructs a BridgeHandler and uses it without gating on whenBound() silently starts getting "handler not bound". The GUI presenters already gate (trackBound(_handler.whenBound())); tests/net/ and any downstream code written against the synchronous contract do not. #585's own body argues this exact property is why Config::asyncRegistrationEnabled must be kept for Qt — "a BridgeHandler would stop being usable on the line after its constructor". SocketBackend has no equivalent flag, so #586 gave it no way to opt out.
What would change the verdict
Bridge::registerHandlerImplhas exactly one call site for acquiring a model, and after morph#569 (#586) and morph#568 (#585) two shipped backends need opposite behaviour from it. #567's surface deliberately removed the only signal that could tell them apart, so the call site cannot satisfy both.This was predicted as "Gap 1" in #585's PR body while the wall was still hypothetical. It is no longer hypothetical: it is now reproducible against a production backend, and it blocks #585.
The two requirements
bindModeldoesregisterHandlerImplmust domorph::net::SocketBackend(native since #586)Completion; the I/O thread settles it laterBridgeHandlerand use it on the next line;executeViafails fast oncurrentId == 0QtWebSocketBackend,asyncRegistrationEnabled = trueCompletionQEventLoopdeadlock that aborts the WASM page — the whole point of #568From
Bridge's side the two are indistinguishable: both are an override ofbindModelthat returns unsettled.include/morph/core/backend.hpp:487-493rules out the discriminator by design:That
boolwasregisterModelAsync's return value, and it was exactly this signal. #568 deletes the overrides that produced it.Verification status
Reproduced, locally and in CI, on
a08db4f9(#585's head, rebased onto master70792bee). GCC 16.2.1, Debug,-DMORPH_BUILD_QT=ON -DMORPH_BUILD_NET=ON. The mutation experiment below was run locally; I did not run it under a sanitizer, and I did not retrieve the CI logs for theclang-tsanandValgrind memchecklegs (the run was still in progress), so my attribution of those two to this same cause is inferred from the leg configuration, not measured.Reproduction, as shipped
tests/net/test_socket_backend.cpp— 5 consecutive local runs, all failing identically:Six tests fail, all
SocketBackend, none of which this branch's diff touches:On master the same leg is green and the same test takes 0.03 s, versus a 2.02 s spin here — it is not timing jitter, the completion never arrives at all.
Mechanism
registerHandlerImplprobesregisterModelAsyncfirst; with #568's overrides deleted it returnsfalse, so control reaches thebindModelfallback.SocketBackend::bindModelis non-blocking, soregisterHandlerImplreturns withbinding->currentId == 0. The next line'sexecutethen hitsbridge.hpp:1463:The test's
.onError([](const std::exception_ptr&) {})swallows that,resultstays at its-1initialiser, andspinUntilburns its full 200 × 10 ms budget. Nothing hangs; the registration simply had not completed.Mutation experiment — both horns measured
Forcing the default blocking
bindModelat that one call site, bypassing the virtual:auto completion = backend->::morph::backend::detail::IBackend::bindModel(...);flips which suite breaks, and breaks the one #568 exists to fix:
All six
SocketBackendfailures clear under that mutation, and fiveQtWebSocketBackendones appear. Neither setting of the one call site is correct, which is the finding: this is not a bug in either backend, it is a missing distinction in the surface between them.Why it is not fixable inside #568
IBackend— contradictsbackend.hpp:487-493, quoted above. That is a core: give IBackend a registration surface whose threading contract is structural, not prose #567-level spec decision, not a step-2 one.executeViaqueue until bound instead of failing fast — changes a documented, separately-tested contract (currentId == 0→ "handler not bound"), and is core: remove IBackend's four async twins and retire the prose threading contract #571's scope.registerHandlerImplchange — reinstates the nested-QEventLoopblock for the WASM path, whichexamples/common/testkit/test_wasm_registration_path_native.cppproves hangs rather than fails.Blast radius beyond the tests
This is not test-only. Any
SocketBackendconsumer that constructs aBridgeHandlerand uses it without gating onwhenBound()silently starts getting"handler not bound". The GUI presenters already gate (trackBound(_handler.whenBound()));tests/net/and any downstream code written against the synchronous contract do not. #585's own body argues this exact property is whyConfig::asyncRegistrationEnabledmust be kept for Qt — "aBridgeHandlerwould stop being usable on the line after its constructor".SocketBackendhas no equivalent flag, so #586 gave it no way to opt out.What would change the verdict
executeViato queue-until-bound and accepts async registration everywhere. Either closes this.SocketBackend's synchronous registration contract was never load-bearing outsidetests/net/would reduce this to "update six tests", and would reopen the option of simply accepting the behaviour change in qt: move QtWebSocketBackend onto the structural registration surface #585. I did not audit downstream consumers, so I cannot rule that in or out.