Skip to content

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

Description

@Yaraslaut

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:

  1. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreSubsystem: corearea: qtSubsystem: qtbugSomething isn't workingtriage: validWell-framed; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions