Skip to content

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

Merged
Yaraslaut merged 4 commits into
masterfrom
feature/26-async-model-registration
Aug 5, 2026
Merged

core+qt: add opt-in asynchronous model registration#36
Yaraslaut merged 4 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

@codecov

codecov Bot commented Aug 3, 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 force-pushed the feature/26-async-model-registration branch from d448c93 to b9d291d Compare August 4, 2026 16:42
Yaraslaut and others added 4 commits August 5, 2026 09:58
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>
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>
Two gaps: the async reply arriving after the caller's own
shared_ptr<HandlerBinding> (Bridge only tracks weak_ptr) is dropped, and
a stale reply from a backend that switchBackend() moved past but is
still alive (via the shared_ptr overload) -- distinct from the
already-covered case where the old backend was destroyed outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cancelPending() now also drains _pendingRegistrations and fails each
one's onError when the connection drops -- previously only exercised
via the happy path (a reply actually arriving). Closes the server while
a registerModelAsync call is still in flight to exercise it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Yaraslaut
Yaraslaut force-pushed the feature/26-async-model-registration branch from 1c6d832 to 3d0f714 Compare August 5, 2026 07:08
@Yaraslaut
Yaraslaut merged commit 353be25 into master Aug 5, 2026
23 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.

Model registration is synchronous only; blocks on WASM main thread

1 participant