Skip to content

qt: QtWebSocketBackend drops contextKey on a blocking private registration, disagreeing with its own async path #594

Description

@Yaraslaut

Found while fixing #587 in morph::net. Not folded into that PR: it lives in include/morph/qt/qt_websocket_backend.hpp / src/qt/qt_websocket_backend.cpp, which the open PR #585 holds, and it is a different backend.

The finding

QtWebSocketBackend disagrees with itself about whether a private registration carries contextKey:

  • QtWebSocketBackend::sendRegisterAsync — src/qt/qt_websocket_backend.cpp:196 — carries it:
    auto env = ::morph::wire::makeRegister(typeId, std::string{contextKey});
  • QtWebSocketBackend does not override registerModelWithContext at all. grep -n "registerModel" include/morph/qt/qt_websocket_backend.hpp lists registerModel, registerModelAsync, registerModelShared and registerModelSharedAsync — no registerModelWithContext. So it inherits IBackend's default (include/morph/core/backend.hpp:162), which does (void)contextKey;.

Which of the two runs is decided by QtWebSocketBackendConfig::asyncRegistrationEnabled, and that defaults to false. Bridge::registerHandlerImpl (include/morph/core/bridge.hpp:1799) tries registerModelAsync first; QtWebSocketBackend::registerModelAsync returns false immediately when the flag is off (src/qt/qt_websocket_backend.cpp:161), so Bridge takes the fallback at bridge.hpp:1853:

if (!started) {
    binding->currentId.store(
        backend->registerModelWithContext(binding->typeId, binding->modelFactory, binding->contextKey).v);

…which is the dropping default.

Two further sites reach the same default:

  • Bridge::switchBackend's re-registration, bridge.hpp:1308, calls registerModelWithContext directly — so a reconnect/backend swap drops the key even for an embedder that enabled the async flag.
  • IBackend::bindModel's default dispatches the empty-primary shape to registerModelWithContext (backend.hpp, BindRequest's table), and QtWebSocketBackend does not override bindModel.

Why it matters

Same mechanism as #587. RemoteServer::attachLogIfConfigured (include/morph/core/remote.hpp:704) returns before consulting the LogProvider when the envelope's contextKey is empty:

void attachLogIfConfigured(::morph::model::detail::IModelHolder& holder, const ::morph::wire::Envelope& env) {
    if (env.contextKey.empty()) {
        return;
    }

So this is not "an audit entry missing its entity key". No log is attached, and a privately-registered instance produces no audit record at all. It fails open.

Two consequences worth stating separately:

  1. The default configuration of the Qt WebSocket transport does not journal private registrations, while SimulatedRemoteBackend (remote.hpp:1849) and — as of net: SocketBackend::bindModel drops contextKey on a private registration, unlike SimulatedRemoteBackend #587 — morph::net::SocketBackend both do. Backends documented as interchangeable disagree.
  2. asyncRegistrationEnabled silently changes audit behaviour. Its doc comment (qt_websocket_backend.hpp:42) is entirely about blocking versus not blocking the Qt event loop; nothing says that flipping it is also what decides whether the instance is journalled. An embedder who turns it on for WASM gets journaling as a side effect, and one who leaves it off does not.

backend.hpp:155's own doc comment on the default already states the rule this violates:

"Backends whose model instances live behind a wire protocol (SimulatedRemoteBackend) override this to carry @p contextKey across — see wire::Envelope::contextKey and RemoteServer::setLogProvider."

QtWebSocketBackend is a wire-protocol backend that does not.

Verification status

Inferred from reading the code. Not reproduced. Stated plainly because the distinction matters here: #587's triage was in the same position for morph::net, and when I actually stood the transport up the defect was real and worse than the reading suggested — but that is evidence about morph::net, not about this.

  • What I did: read the four call sites above on 992b190c plus my net: SocketBackend::bindModel drops contextKey on a private registration, unlike SimulatedRemoteBackend #587 branch, and confirmed by grep that no registerModelWithContext or bindModel override exists in include/morph/qt/qt_websocket_backend.hpp.

  • What I did not do: build the Qt transport (MORPH_BUILD_QT=ON) or run anything against a QtWebSocketServer. Every claim about runtime behaviour here is a reading of the dispatch chain, not a measurement.

  • Corroboration that is already in the tree, and is not my inference: docs/spec/core/backend.md:1215 documents the drop as current behaviour —

    "registerModelWithContext is not overridden — the default drops the contextKey, so this transport does not carry a context key to the server's LogProvider."

    and tests/test_action_log_phase2.cpp:347 is a passing test pinning the server-side half (makeRegister("P2_Model") with no key ⇒ REQUIRE_FALSE(providerCalled)), which is byte-for-byte the envelope the blocking path sends.

  • Not measured: whether any shipped rung or example registers privately over QtWebSocketBackend with a non-empty contextKey today. If none does, this is latent rather than active — which changes priority, not validity.

What would close it

The Qt analogue of the test #587's PR added for morph::net (tests/net/test_socket_backend.cpp, "a private registration carries contextKey to the server's log provider"): stand up a QtWebSocketServer over a RemoteServer with a LogProvider installed, register privately through QtWebSocketBackend with a non-empty contextKey, and assert the provider was consulted with that key — with asyncRegistrationEnabled left at its false default, since that is the path in question.

Per AGENTS.md's "would this still pass if the feature did nothing": assert on providerCalled/entityKey, not on the registration succeeding. It succeeds today. That is exactly the assertion that failed for morph::net ({ } == { "SbEchoModel:acct-587" }) while registration itself passed.

The fix is then the same two lines #587 took: override registerModelWithContext to build makeRegister(typeId, std::string{contextKey}), and have registerModel forward to it with an empty key.

What would change the verdict

  • Close this if the Qt transport is deliberately excluded from journaling private registrations — but then the disagreement with SimulatedRemoteBackend and SocketBackend is the bug instead, backend.hpp:155 needs rewriting, and asyncRegistrationEnabled still must not be what decides it.
  • Close this if a reproduction attempt shows the key does arrive — in which case my reading of the Bridge fallback chain is wrong and I would want to know where.

Sequencing

Touches files held by the open, blocked PR #585 (qt_websocket_backend.hpp / .cpp) and would need the same docs/spec/core/backend.md edit #587's PR is already waiting on. Should be sequenced after #585 clears.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GS5K2vqZtC4xbRiGJHT7jH

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: journalSubsystem: journalarea: 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