Skip to content

core+qt: add opt-in asynchronous model registration - #36

Open
Yaraslaut wants to merge 2 commits into
masterfrom
feature/26-async-model-registration
Open

core+qt: add opt-in asynchronous model registration#36
Yaraslaut wants to merge 2 commits into
masterfrom
feature/26-async-model-registration

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Summary

  • Bridge registers a model via IBackend::registerModel, which is synchronous. QtWebSocketBackend implements that with a nested QEventLoop parked until the reply arrives. On a WASM main thread, Qt refuses to spin a nested loop at all, so the very first registerModel call aborts the page — exactly the issue's repro.
  • Adds IBackend::registerModelAsync(typeId, factory, contextKey, onRegistered, onError), defaulting to "unsupported" (false, calls neither callback) so every backend that hasn't opted in is unaffected. Bridge::registerHandler() (both overloads) prefers this path when a backend offers one, falling back to the synchronous call otherwise.
  • The success callback guards against a switchBackend() racing ahead of a still-pending registration, reusing the exact weak_ptr<IBackend> + liveness-token pattern installReconnectHandler already uses — a stale reply can never clobber the fresh id switchBackend's own re-registration already assigned. Verified with a dedicated regression test.
  • QtWebSocketBackend overrides registerModelAsync, matching execute()'s existing callId-based reply matching (no protocol change needed — the server already echoes callId on every reply kind, register included).
  • Found and fixed while implementing: making the Qt override unconditional silently broke the existing Qt test suite — measured directly, 20 of 50 test cases failed, because code that fires an action immediately after constructing a BridgeHandler (assuming synchronous-enough registration) now sees "handler not bound" instead. Gated the override behind a new QtWebSocketBackendConfig::asyncRegistrationEnabled, defaulting to false: every existing embedder's behavior is unchanged; a WASM build opts in explicitly and adapts its call sites to wait for registration before firing an action. Confirmed by running the real Qt/WebSocket test suite against Qt6 both before and after this fix.
  • Scope: only the plain (non-shared) registration path — a BridgeHandler's initial construction — goes through this. registerModelShared/attachModel (shared/keyed handlers) and the re-registration switchBackend()/reconnect-handler paths remain synchronous; making those async too is a larger change to Bridge's locking model, left for a follow-up if it proves necessary.
  • Design docs updated: docs/spec/core/backend.md (new "Asynchronous registration" section, IBackend/QtWebSocketBackend API reference rows) and docs/spec/core/bridge.md (registerHandler behavior).

Test plan

  • tests/test_async_registration.cpp (new): async path preferred and binding starts unbound; async execute works once registration completes; onError leaves the binding unbound with no crash; a stale reply after switchBackend() does not clobber the new id; a reply arriving after ~Bridge() is a safe no-op; a backend with no async support still falls back to the synchronous path.
  • tests/qt/test_qt_websocket.cpp (new case): end-to-end against a real Qt WebSocket server — registration doesn't block, and execute succeeds once the deferred registration completes.
  • Full suite: ./build/tests/morph_tests — all 817 test cases / 8305 assertions pass.
  • Full Qt suite built and run against real Qt6 (-DMORPH_BUILD_QT=ON): 51 test cases / 340 assertions pass — confirmed byte-for-byte matching the pre-change baseline (334 assertions / 50 cases) plus the one new case.

Closes #26

🤖 Generated with Claude Code

Bridge registers a model by calling IBackend::registerModel, which is
synchronous -- it must have the server-assigned ModelId before it returns.
QtWebSocketBackend implements that via a nested QEventLoop parked until the
reply arrives. On a WASM main thread Qt refuses to do this at all
("WaitForMoreEvents is not supported on the main thread without asyncify")
and aborts the module, so a Qt/QML client compiled to WebAssembly cannot
register a single model against a remote backend -- the very first
registerModel call kills the page.

Add IBackend::registerModelAsync(typeId, factory, contextKey, onRegistered,
onError): an optional non-blocking counterpart defaulting to "unsupported"
(returns false, calls neither callback) so every backend that hasn't opted
in is unaffected. Bridge::registerHandler() (both overloads) now prefers this
path when a backend offers one, adding the binding to _handlers and issuing
the async call before taking _mtx (so a backend that -- unlike any documented
here -- invoked a callback synchronously could not self-deadlock), falling
back to the synchronous registerModelWithContext otherwise. The success
callback guards against a switchBackend() racing ahead of a still-pending
registration (same weak_ptr<IBackend> + liveness-token pattern
installReconnectHandler already uses) so a stale reply can never overwrite
the fresh id switchBackend's own re-registration already assigned.

QtWebSocketBackend overrides registerModelAsync, matching execute()'s
existing callId-based reply matching (the server already echoes callId on
every reply kind, register included -- no protocol change needed). Gated by
a new QtWebSocketBackendConfig::asyncRegistrationEnabled, defaulting to
false: making the override unconditional broke the existing Qt test suite
(measured directly -- 20 of 50 test cases failed) because any caller that
fires an action immediately after constructing a BridgeHandler, assuming
synchronous-enough-to-execute-next-line registration, now sees "handler not
bound" instead. Defaulting the flag off preserves every existing embedder's
behavior exactly; a WASM build opts in explicitly and adapts its call sites
to wait for registration before firing an action.

Scope: only the plain (non-shared) registration path -- a BridgeHandler's
initial construction -- goes through registerModelAsync. registerModelShared/
attachModel (shared/keyed handlers) and the re-registration switchBackend()/
the reconnect handler perform after a backend swap remain synchronous; making
those async too is a larger change to Bridge's locking model, left for a
follow-up if it proves necessary.

Closes #26

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.36842% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
include/morph/core/bridge.hpp 96.77% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Doxygen's WARN_AS_ERROR build fails on any undocumented parameter;
QtWebSocketBackend::registerModelAsync's factory param (unused — this
backend has no local model to construct) was missing its @param tag.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
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.

Model registration is synchronous only; blocks on WASM main thread

1 participant