From e9029c026d8fbc5a871caab74350eadb2ab9f586 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 21:05:57 +0300 Subject: [PATCH 1/3] core+qt: add setConnectHandler/setDisconnectHandler to IBackend setReconnectHandler deliberately fires only on the second and later connects -- it exists so Bridge can re-register handlers after a drop, and skipping the initial connect is correct for that purpose. There was no notification for the first successful connect (waitForConnected() answers this but blocks, unusable on a browser/WASM main thread) or for a disconnect at all (a client learned the socket dropped only indirectly, when a later action failed) -- both gaps a connection-state UI needs closed. Add setConnectHandler (fires on every successful connect, first included) and setDisconnectHandler (fires whenever the transport drops, before any reconnect is scheduled -- so an observer sees the disconnected state even when a retry follows immediately) to IBackend itself, with the same no-op-default pattern setReconnectHandler already established, rather than only on QtWebSocketBackend: connection state is a property of any transport-backed backend, and a UI observing it shouldn't have to downcast to a concrete type. A purely local backend has no meaningful connection state, so the base-class hook is simply inert for it -- matching precedent exactly, since setReconnectHandler already works this way. QtWebSocketBackend's connected/disconnected QWebSocket signal slots invoke the new handlers (if installed) at the same points they already invoke _reconnectHandler/schedule a reconnect. Purely additive: setReconnectHandler keeps its current semantics and every existing embedder is unaffected. Closes #29 Signed-off-by: Yaraslau Tamashevich --- docs/spec/core/backend.md | 81 ++++++++++++++++++----- include/morph/core/backend.hpp | 39 +++++++++++ include/morph/qt/qt_websocket_backend.hpp | 15 +++++ src/qt/qt_websocket_backend.cpp | 14 ++++ tests/qt/test_qt_websocket.cpp | 53 +++++++++++++++ 5 files changed, 187 insertions(+), 15 deletions(-) diff --git a/docs/spec/core/backend.md b/docs/spec/core/backend.md index 3265c1c6..06334785 100644 --- a/docs/spec/core/backend.md +++ b/docs/spec/core/backend.md @@ -27,6 +27,7 @@ and react to backend changes. - [The dispatch struct — `ActionCall`](#the-dispatch-struct--actioncall) - [The abstract interface — `IBackend`](#the-abstract-interface--ibackend) +- [Connect/disconnect notifications](#connectdisconnect-notifications) - [Error types](#error-types) - [`LocalBackend` — in-process execution](#localbackend--in-process-execution) - [`RemoteServer` — server-side message handler](#remoteserver--server-side-message-handler) @@ -73,7 +74,45 @@ holds a `unique_ptr` and delegates all model operations to it. | `execute(mid, call, cbExec)` | Dispatches `call` against the model identified by `mid`. Returns a `Completion>`. | | `notifyBackendChanged()` | Called by `Bridge::switchBackend()` after all handlers are re-registered. | | `cancelPending(exc)` | Resolves every still-pending completion with `exc`. Called on the outgoing backend during `switchBackend()` and in `Bridge`'s destructor. After this call, any later `setValue`/`setException` on those states is a no-op. | -| `setReconnectHandler(handler)` | Installs a callback invoked when the backend reconnects to its peer. Used by backends with transport (e.g. `QtWebSocketBackend`). Default implementation is a no-op. | +| `setReconnectHandler(handler)` | Installs a callback invoked when the backend reconnects to its peer. Fires only on the *second and later* connects, never the first — used by `Bridge` to re-register handlers after a drop. Used by backends with transport (e.g. `QtWebSocketBackend`). Default implementation is a no-op. | +| `setConnectHandler(handler)` | Installs a callback invoked on every successful connect, including the first — the complementary hook `setReconnectHandler` deliberately skips (see [Connect/disconnect notifications](#connectdisconnect-notifications)). Default implementation is a no-op. | +| `setDisconnectHandler(handler)` | Installs a callback invoked whenever the transport drops, before any reconnect is scheduled. Default implementation is a no-op. | + +## Connect/disconnect notifications + +`setReconnectHandler` exists so `Bridge` can re-register every live +`HandlerBinding` after a transport drop and re-establish; it deliberately +fires only on the *second and later* connects — on the first connect there +is nothing yet to re-register. That leaves two gaps a UI reflecting live +connection state needs closed: + +- **First connect.** `waitForConnected()` (where a concrete backend offers + one, e.g. `QtWebSocketBackend`) answers this, but it blocks the calling + thread. On a browser/WASM target that hangs the page outright; even on + desktop it means blocking startup on a network round-trip. +- **Disconnect.** There was no hook at all: a client learned the socket + dropped only indirectly, when a later action failed. + +`setConnectHandler`/`setDisconnectHandler` close both, on `IBackend` itself +(not only on `QtWebSocketBackend`) with the same no-op-default pattern +`setReconnectHandler` already established — a UI observing connection state +shouldn't have to downcast to a concrete backend type to do it, and a +backend with no meaningful connection state (`LocalBackend`) simply never +invokes either. `setConnectHandler`'s callback fires on *every* successful +connect, first included; `setDisconnectHandler`'s fires whenever the +transport drops, **before** any reconnect is scheduled, so an observer sees +the disconnected state even when a retry follows immediately (an instant +successful reconnect must not look, from the UI's perspective, like nothing +happened). Both are invoked on the backend's own thread, and `nullptr` +clears either — matching `setReconnectHandler`'s existing contract exactly. +Purely additive: `setReconnectHandler` keeps its current semantics, and +every existing embedder is unaffected. + +`QtWebSocketBackend` is currently the only backend that overrides either: +its `connected`/`disconnected` `QWebSocket` signal slots invoke +`_connectHandler`/`_disconnectHandler` (if installed) at the same points +they already invoke `_reconnectHandler`/schedule a reconnect — see that +section below. ## Error types @@ -119,7 +158,7 @@ Four exception types are thrown into in-flight `Completion`s: Delivery is asynchronous and serialised against that model's `execute` tasks; it never runs under `_regMtx` or `Bridge::_mtx`, so a sink that re-enters the bridge cannot deadlock. -- `setReconnectHandler` — no-op (no transport to reconnect). +- `setReconnectHandler`/`setConnectHandler`/`setDisconnectHandler` — no-op (no transport to (dis)connect). Each model instance gets its own strand so actions are serialised per-model without a global lock on the pool. @@ -548,24 +587,30 @@ in the reply (see wire.md). The state machine: - On **`connected`**: sets `_connected`, resets the backoff delay to - `initialReconnectDelay`, quits any parked sync loop. It fires the - `_reconnectHandler` **only on subsequent connects** (`_everConnected` was - already true) — never on the first connect, because initial handler - registration is driven by the `BridgeHandler` constructors, not the reconnect - path. -- On **`disconnected`**: clears `_connected` and immediately calls - `cancelPending(DisconnectedError{})`, resolving every in-flight execute with - `DisconnectedError`. If not shutting down, `reconnectEnabled`, and the socket - had *ever* connected, it schedules a reconnect with the current backoff delay, - then multiplies the delay by `backoffMultiplier` (capped at - `maxReconnectDelay`) for the next attempt. A connection that never succeeded - the first time is **not** retried. + `initialReconnectDelay`, quits any parked sync loop. Fires `_connectHandler` + (if installed) unconditionally — every successful connect, first included. + It then fires the `_reconnectHandler` **only on subsequent connects** + (`_everConnected` was already true) — never on the first connect, because + initial handler registration is driven by the `BridgeHandler` constructors, + not the reconnect path. See [Connect/disconnect notifications](#connectdisconnect-notifications). +- On **`disconnected`**: clears `_connected`, then fires `_disconnectHandler` + (if installed) — **before** the reconnect scheduling below, so an observer + sees the disconnected state even when a retry follows immediately. Then + immediately calls `cancelPending(DisconnectedError{})`, resolving every + in-flight execute with `DisconnectedError`. If not shutting down, + `reconnectEnabled`, and the socket had *ever* connected, it schedules a + reconnect with the current backoff delay, then multiplies the delay by + `backoffMultiplier` (capped at `maxReconnectDelay`) for the next attempt. A + connection that never succeeded the first time is **not** retried. - `attemptReconnect` re-opens the socket; if it fails, `QWebSocket` fires `disconnected` again and the cycle repeats with the grown backoff. `Bridge` installs a `_reconnectHandler` (via `setReconnectHandler`) that re-registers every live `HandlerBinding` so model ids stay valid after the server assigns fresh ones on the new connection (cross-ref bridge.md). +`setConnectHandler`/`setDisconnectHandler` are independent of that — an +application installs them directly on the backend (not through `Bridge`) to +drive its own connection-state UI. **`waitForConnected(timeoutMs = 5000)`** pumps the Qt event loop until the socket connects or the timeout elapses; returns the current `_connected` flag. Intended @@ -993,7 +1038,9 @@ thread to marshal onto. | `execute` | `virtual Completion> execute(ModelId, ActionCall, IExecutor*)` | Pure virtual. | | `notifyBackendChanged` | `virtual void notifyBackendChanged()` | Pure virtual. | | `cancelPending` | `virtual void cancelPending(const exception_ptr&)` | Pure virtual. | -| `setReconnectHandler` | `virtual void setReconnectHandler(const function&)` | Default: no-op. | +| `setReconnectHandler` | `virtual void setReconnectHandler(const function&)` | Default: no-op. Fires only on the second and later connects. | +| `setConnectHandler` | `virtual void setConnectHandler(const function&)` | Default: no-op. Fires on every successful connect, first included. | +| `setDisconnectHandler` | `virtual void setDisconnectHandler(const function&)` | Default: no-op. Fires whenever the transport drops, before any reconnect is scheduled. | ### Error types @@ -1069,6 +1116,8 @@ thread to marshal onto. | `notifyBackendChanged()` | No-op. | | `cancelPending(exc)` | Drains `_pending` under `_pendingMtx`, delivers `exc` to each state. | | `setReconnectHandler(handler)` | Stores the handler; invoked on the Qt thread after every *subsequent* connect. `nullptr` clears. | +| `setConnectHandler(handler)` | Stores the handler; invoked on the Qt thread after every successful connect, first included. `nullptr` clears. | +| `setDisconnectHandler(handler)` | Stores the handler; invoked on the Qt thread whenever the socket drops, before reconnect scheduling. `nullptr` clears. | ### `QtWebSocketServerConfig` (namespace `morph::qt`) @@ -1147,6 +1196,8 @@ not a behavior change to the existing loopback-only default. | `SimulatedRemoteBackend` factory ignored | Model construction delegated to `RemoteServer`'s `ModelRegistryFactory` | The factory closure lives on the client side; the server owns the actual instances. | | `cancelPending` snapshots | Weak-ptr snapshot under lock, then resolves outside | Avoids holding the lock while delivering exceptions to each state, preventing deadlock if a callback re-enters the backend. | | `setReconnectHandler` | Default no-op | Only backends with a transport layer (e.g. `QtWebSocketBackend`) need to react to reconnects. `LocalBackend` and `SimulatedRemoteBackend` never invoke it. | +| `setConnectHandler`/`setDisconnectHandler` on `IBackend`, not only `QtWebSocketBackend` | Same no-op-default pattern as `setReconnectHandler` | Connection state is a property of any transport-backed backend; a UI observing it shouldn't have to downcast to a concrete backend type. A purely local backend has no meaningful connection state, so the base-class hook is simply inert for it — no behavior change, matching the existing `setReconnectHandler` precedent exactly. | +| `setDisconnectHandler` fires before reconnect scheduling | Ordering choice, not incidental | An instant successful reconnect must not look, from an observer's perspective, like nothing happened — the disconnected state must be visible even when the very next thing that happens is a fresh `connected`. | | Strand-per-model | `StrandExecutor` serialises actions per `ModelId` | Actions against the same model run sequentially; different models can run in parallel. No global lock on the pool. | | Overwrite `session.principal` on remote execute | `authenticate()` result replaces the client claim before dispatch | The client-asserted `Context::principal` is untrusted; a verifying authorizer makes the token-derived identity authoritative so `session::current()->principal` inside a model is trustworthy. Non-verifying authorizers return `nullopt` and change nothing. | | Opaque model ids | Monotonic counter run through a keyed 4-round Feistel permutation (`detail::OpaqueIdGenerator`), key drawn from `std::random_device` at construction | Guarantees uniqueness (Feistel networks are bijections for any round function) while making ids unguessable without the key; self-contained, no external crypto dependency — same posture as the reference HMAC-SHA256 in `session_auth.hpp`. | diff --git a/include/morph/core/backend.hpp b/include/morph/core/backend.hpp index 39892f82..58c05202 100644 --- a/include/morph/core/backend.hpp +++ b/include/morph/core/backend.hpp @@ -242,11 +242,50 @@ struct IBackend { /// `QtWebSocketBackend`). `Bridge` installs a handler that re-registers every /// live `HandlerBinding` so model ids stay valid after the reconnect. /// + /// Deliberately fires only on the *second and later* connects, never the + /// first — re-registering handlers only makes sense after a drop; on the + /// first connect there is nothing yet to re-register. See + /// `setConnectHandler` for a hook that also covers the first connect. + /// /// Default implementation: store-and-ignore. Backends with no transport (e.g. /// `LocalBackend`) never invoke it. /// @param handler Callable invoked on the backend's transport thread after a /// successful reconnect. Pass `nullptr` to clear. virtual void setReconnectHandler(const std::function& handler) { (void)handler; } + + /// @brief Installs a callback invoked on every successful connect, including the first. + /// + /// `setReconnectHandler` deliberately skips the first connect (there is + /// nothing to re-register yet); this is the complementary hook for UI that + /// needs to know the transport is up at all — a "connecting… / connected / + /// offline" status indicator, for instance. `waitForConnected()` (where a + /// concrete backend offers one, e.g. `QtWebSocketBackend`) answers the same + /// question but blocks the calling thread, which is unusable on a + /// browser/WASM main thread and undesirable even on desktop if it means + /// blocking startup on a network round-trip; this hook is fired + /// asynchronously instead. + /// + /// Default implementation: store-and-ignore. Backends with no transport (e.g. + /// `LocalBackend`) never invoke it. + /// @param handler Callable invoked on the backend's transport thread after + /// every successful connect (first and subsequent). Pass + /// `nullptr` to clear. + virtual void setConnectHandler(const std::function& handler) { (void)handler; } + + /// @brief Installs a callback invoked whenever the transport drops. + /// + /// Fires before any reconnect is scheduled, so an observer sees the + /// disconnected state even when a retry follows immediately — a status + /// indicator that skipped straight from "connected" to a fresh "connected" + /// (after an instant reconnect) would misreport an outage that did happen. + /// Without this hook a client learns the socket dropped only indirectly, + /// when a later action fails. + /// + /// Default implementation: store-and-ignore. Backends with no transport (e.g. + /// `LocalBackend`) never invoke it. + /// @param handler Callable invoked on the backend's transport thread whenever + /// the connection drops. Pass `nullptr` to clear. + virtual void setDisconnectHandler(const std::function& handler) { (void)handler; } }; // NOLINTEND(cppcoreguidelines-special-member-functions) diff --git a/include/morph/qt/qt_websocket_backend.hpp b/include/morph/qt/qt_websocket_backend.hpp index 2a2d87ba..651de8c8 100644 --- a/include/morph/qt/qt_websocket_backend.hpp +++ b/include/morph/qt/qt_websocket_backend.hpp @@ -200,6 +200,19 @@ class QtWebSocketBackend : public ::morph::backend::detail::IBackend { /// Pass `nullptr` to clear. void setReconnectHandler(const std::function& handler) override; + /// @brief Installs a handler invoked on every successful connect, including the first. + /// @param handler Callable invoked on the Qt thread after every successful connect + /// (first and subsequent). Pass `nullptr` to clear. + void setConnectHandler(const std::function& handler) override; + + /// @brief Installs a handler invoked whenever the socket drops. + /// + /// Fires before reconnect scheduling, so an observer sees the disconnected + /// state even when a retry follows immediately. + /// @param handler Callable invoked on the Qt thread whenever the connection + /// drops. Pass `nullptr` to clear. + void setDisconnectHandler(const std::function& handler) override; + private: /// @brief Sends @p msg synchronously by blocking the Qt thread via a nested event loop. std::string sendSync(const std::string& msg); @@ -225,6 +238,8 @@ class QtWebSocketBackend : public ::morph::backend::detail::IBackend { bool _everConnected{false}; bool _shuttingDown{false}; std::function _reconnectHandler; + std::function _connectHandler; + std::function _disconnectHandler; std::string _pendingReply; QEventLoop* _syncLoop{nullptr}; diff --git a/src/qt/qt_websocket_backend.cpp b/src/qt/qt_websocket_backend.cpp index 23c30811..614a404f 100644 --- a/src/qt/qt_websocket_backend.cpp +++ b/src/qt/qt_websocket_backend.cpp @@ -39,6 +39,11 @@ QtWebSocketBackend::QtWebSocketBackend(QUrl serverUrl, ::morph::model::detail::A if (_syncLoop) { _syncLoop->quit(); } + // Fires on every successful connect, first included -- the general + // "transport is up" notification a status indicator wants. + if (_connectHandler) { + _connectHandler(); + } // Fire the reconnect handler only on subsequent connects, never on the // first one — initial registration is handled by the BridgeHandler ctors. if (isReconnect && _reconnectHandler) { @@ -47,6 +52,11 @@ QtWebSocketBackend::QtWebSocketBackend(QUrl serverUrl, ::morph::model::detail::A }); QObject::connect(&_socket, &QWebSocket::disconnected, [this]() { _connected = false; + // Fires before reconnect scheduling below, so an observer sees the + // disconnected state even when a retry follows immediately. + if (_disconnectHandler) { + _disconnectHandler(); + } // Unblock any parked synchronous call (e.g. a register whose reply is // outstanding). Without this the nested QEventLoop in sendSync never // quits, freezing the Qt thread forever. We clear _pendingReply first so @@ -266,6 +276,10 @@ void QtWebSocketBackend::cancelPending(const std::exception_ptr& exc) { void QtWebSocketBackend::setReconnectHandler(const std::function& handler) { _reconnectHandler = handler; } +void QtWebSocketBackend::setConnectHandler(const std::function& handler) { _connectHandler = handler; } + +void QtWebSocketBackend::setDisconnectHandler(const std::function& handler) { _disconnectHandler = handler; } + void QtWebSocketBackend::scheduleReconnect() { _reconnectTimer.start(static_cast(_currentReconnectDelay.count())); // Pre-compute the next backoff so the timer above used the *current* one. diff --git a/tests/qt/test_qt_websocket.cpp b/tests/qt/test_qt_websocket.cpp index 1ae31c33..21d93e58 100644 --- a/tests/qt/test_qt_websocket.cpp +++ b/tests/qt/test_qt_websocket.cpp @@ -324,6 +324,59 @@ TEST_CASE("morph::qt::QtWebSocketBackend connecting to closed port fails to conn REQUIRE_FALSE(backendPtr->waitForConnected(200)); } +TEST_CASE( + "morph::qt::QtWebSocketBackend: setConnectHandler fires on the first connect (unlike setReconnectHandler)", + "[qt][ws][issue29]") { + ensureApp(); + morph::exec::ThreadPoolExecutor serverPool{2}; + auto server = std::make_shared(serverPool); + morph::qt::QtWebSocketServer wsServer{*server, 0}; + REQUIRE(wsServer.listen()); + + QUrl url{QString("ws://127.0.0.1:%1").arg(wsServer.port())}; + morph::qt::QtWebSocketBackend backend{url}; + + std::atomic connectCount{0}; + std::atomic reconnectCount{0}; + backend.setConnectHandler([&] { connectCount.fetch_add(1); }); + backend.setReconnectHandler([&] { reconnectCount.fetch_add(1); }); + + REQUIRE(backend.waitForConnected()); + pumpUntil([&] { return connectCount.load() >= 1; }); + CHECK(connectCount.load() == 1); + CHECK(reconnectCount.load() == 0); // must not fire on the first connect +} + +TEST_CASE("morph::qt::QtWebSocketBackend: setDisconnectHandler fires when the socket drops, before reconnect", + "[qt][ws][issue29]") { + ensureApp(); + morph::exec::ThreadPoolExecutor serverPool{2}; + auto server = std::make_shared(serverPool); + + std::atomic disconnected{false}; + // Reconnect disabled: isolates the disconnect notification from any + // automatic reconnect attempt racing the assertions below. + std::unique_ptr backendPtr; + { + morph::qt::QtWebSocketServer wsServer{*server, 0}; + REQUIRE(wsServer.listen()); + + QUrl url{QString("ws://127.0.0.1:%1").arg(wsServer.port())}; + backendPtr = std::make_unique( + url, morph::model::detail::defaultDispatcher(), morph::model::detail::defaultRegistry(), std::nullopt, + morph::qt::QtWebSocketBackend::Config{.reconnectEnabled = false}); + REQUIRE(backendPtr->waitForConnected()); + + backendPtr->setDisconnectHandler([&] { disconnected.store(true); }); + CHECK_FALSE(disconnected.load()); + + // wsServer goes out of scope here -- the client socket drops, while + // backendPtr (declared in the outer scope) survives to observe it. + } + pumpUntil([&] { return disconnected.load(); }); + CHECK(disconnected.load()); +} + TEST_CASE("morph::qt::QtWebSocketBackend reconnects to a fresh server on the same port", "[qt][ws][lifecycle]") { ensureApp(); morph::exec::ThreadPoolExecutor serverPool{2}; From a5fed12273888ab8a36adbcada4515e46ef25897 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 22:10:56 +0300 Subject: [PATCH 2/3] tests: cover IBackend::setConnectHandler/setDisconnectHandler default no-ops QtWebSocketBackend overrides both hooks added for #29, so the base IBackend "store-and-ignore" bodies (backend.hpp:273,288) were never exercised, tripping the codecov/patch gate at 0% on PR #39. LocalBackend does not override either, so a direct call through it hits the real default implementation. Signed-off-by: Yaraslau Tamashevich --- tests/test_coverage_push95.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_coverage_push95.cpp b/tests/test_coverage_push95.cpp index 0ca63475..d2b45de5 100644 --- a/tests/test_coverage_push95.cpp +++ b/tests/test_coverage_push95.cpp @@ -523,3 +523,25 @@ TEST_CASE("morph::offline::SyncWorker: a payload is dead-lettered after kMaxAtte REQUIRE(after.failed == 0); REQUIRE(after.deadLettered == 0); } + +// ── backend.hpp:273,288 — IBackend::setConnectHandler/setDisconnectHandler default bodies ── +// +// QtWebSocketBackend overrides both, so the base "store-and-ignore" bodies are +// otherwise dead. LocalBackend does not override either, so calling them +// through a LocalBackend exercises the real default implementation. + +TEST_CASE("morph::backend::LocalBackend: setConnectHandler/setDisconnectHandler are store-and-ignore no-ops", + "[coverage][backend]") { + morph::exec::ThreadPoolExecutor pool{1}; + ::morph::backend::LocalBackend backend{pool}; + + bool fired = false; + REQUIRE_NOTHROW(backend.setConnectHandler([&] { fired = true; })); + REQUIRE_NOTHROW(backend.setDisconnectHandler([&] { fired = true; })); + // LocalBackend has no transport, so neither handler is ever invoked. + REQUIRE_FALSE(fired); + + // Clearing with nullptr, as the docs promise, must not throw either. + REQUIRE_NOTHROW(backend.setConnectHandler(nullptr)); + REQUIRE_NOTHROW(backend.setDisconnectHandler(nullptr)); +} From 9741074d2d6d0b2bdfb6856275bec54d35b0b1cc Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 4 Aug 2026 20:02:12 +0300 Subject: [PATCH 3/3] tests(qt): cover nullptr-clear and disconnect-before-reconnect ordering Two gaps flagged during code review and left as "worth noting" rather than fixed at the time: 1. setConnectHandler/setDisconnectHandler/setReconnectHandler's documented nullptr-to-clear behavior had zero test coverage. Add a test that installs all three, drives one full disconnect/reconnect cycle to confirm each fires, clears all three via nullptr, drives a second cycle, and confirms none of the counters move. 2. The class doc comment's claim that the disconnect handler "fires before any reconnect is scheduled" was unverified -- the existing test sidesteps it via reconnectEnabled = false. Add a test where the disconnect handler itself observes reconnectCount == 0 at the moment it runs, then confirms the reconnect handler does eventually fire once a fresh server comes up on the same port. Verified locally: all 4 [issue29]-tagged cases pass across 3 repeated runs (timing-sensitive reconnect assertions), full Qt suite (57 cases) passes. Signed-off-by: Yaraslau Tamashevich --- tests/qt/test_qt_websocket.cpp | 110 +++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/qt/test_qt_websocket.cpp b/tests/qt/test_qt_websocket.cpp index 21d93e58..c9617173 100644 --- a/tests/qt/test_qt_websocket.cpp +++ b/tests/qt/test_qt_websocket.cpp @@ -377,6 +377,116 @@ TEST_CASE("morph::qt::QtWebSocketBackend: setDisconnectHandler fires when the so CHECK(disconnected.load()); } +TEST_CASE( + "morph::qt::QtWebSocketBackend: setDisconnectHandler fires strictly before any reconnect activity", + "[qt][ws][issue29]") { + ensureApp(); + morph::exec::ThreadPoolExecutor serverPool{2}; + auto server = std::make_shared(serverPool); + + quint16 port = 0; + std::unique_ptr backendPtr; + std::atomic reconnectCount{0}; + std::atomic disconnectSawNoReconnectYet{false}; + std::atomic disconnectFired{false}; + { + morph::qt::QtWebSocketServer wsServer{*server, 0}; + REQUIRE(wsServer.listen()); + port = wsServer.port(); + + backendPtr = std::make_unique( + QUrl{QString("ws://127.0.0.1:%1").arg(port)}, morph::model::detail::defaultDispatcher(), + morph::model::detail::defaultRegistry(), std::nullopt, + morph::qt::QtWebSocketBackend::Config{.initialReconnectDelay = std::chrono::milliseconds{10}}); + REQUIRE(backendPtr->waitForConnected()); + + backendPtr->setReconnectHandler([&] { reconnectCount.fetch_add(1); }); + backendPtr->setDisconnectHandler([&] { + // The doc-comment-claimed ordering: disconnect must observe zero + // reconnect activity, since scheduleReconnect() -- let alone a + // completed reconnect -- has not run yet at this point. + disconnectSawNoReconnectYet.store(reconnectCount.load() == 0); + disconnectFired.store(true); + }); + + // wsServer goes out of scope here -- the client socket drops. + } + pumpUntil([&] { return disconnectFired.load(); }); + CHECK(disconnectFired.load()); + CHECK(disconnectSawNoReconnectYet.load()); + + // Bring a fresh server up on the same port so the backend's own automatic + // reconnect (Config::reconnectEnabled defaults to true) succeeds, proving + // the reconnect handler does eventually fire (just strictly after disconnect). + morph::qt::QtWebSocketServer wsServer{*server, port}; + REQUIRE(wsServer.listen()); + // waitForConnected pumps the event loop until the automatic reconnect lands. + REQUIRE(backendPtr->waitForConnected(2000)); +} + +TEST_CASE( + "morph::qt::QtWebSocketBackend: passing nullptr to setConnectHandler/setDisconnectHandler/setReconnectHandler " + "clears them", + "[qt][ws][issue29]") { + ensureApp(); + morph::exec::ThreadPoolExecutor serverPool{2}; + auto server = std::make_shared(serverPool); + + morph::qt::QtWebSocketServer wsServer{*server, 0}; + REQUIRE(wsServer.listen()); + quint16 const port = wsServer.port(); + + auto backendPtr = std::make_unique( + QUrl{QString("ws://127.0.0.1:%1").arg(port)}, morph::model::detail::defaultDispatcher(), + morph::model::detail::defaultRegistry(), std::nullopt, + morph::qt::QtWebSocketBackend::Config{.initialReconnectDelay = std::chrono::milliseconds{10}}); + REQUIRE(backendPtr->waitForConnected()); + + std::atomic connectCount{0}; + std::atomic disconnectCount{0}; + std::atomic reconnectCount{0}; + backendPtr->setConnectHandler([&] { connectCount.fetch_add(1); }); + backendPtr->setDisconnectHandler([&] { disconnectCount.fetch_add(1); }); + backendPtr->setReconnectHandler([&] { reconnectCount.fetch_add(1); }); + + // First cycle: drop the connection (fires disconnect), then have the + // backend auto-reconnect to a fresh server on the same port (fires + // reconnect, which itself fires connect too). + wsServer.close(); + pumpUntil([&] { return disconnectCount.load() >= 1; }); + REQUIRE(disconnectCount.load() == 1); + + morph::qt::QtWebSocketServer wsServer2{*server, port}; + REQUIRE(wsServer2.listen()); + REQUIRE(backendPtr->waitForConnected(2000)); + pumpUntil([&] { return reconnectCount.load() >= 1; }); + // The initial connect fired before setConnectHandler was installed above, + // so only the reconnect's connect signal is observed here. + REQUIRE(connectCount.load() == 1); + REQUIRE(reconnectCount.load() == 1); + + // Clear all three handlers, then force a second disconnect/reconnect cycle. + backendPtr->setConnectHandler(nullptr); + backendPtr->setDisconnectHandler(nullptr); + backendPtr->setReconnectHandler(nullptr); + + int const connectCountBefore = connectCount.load(); + int const disconnectCountBefore = disconnectCount.load(); + int const reconnectCountBefore = reconnectCount.load(); + + wsServer2.close(); + pumpUntil([] { return false; }, 10); // give the drop time to reach the client + + morph::qt::QtWebSocketServer wsServer3{*server, port}; + REQUIRE(wsServer3.listen()); + REQUIRE(backendPtr->waitForConnected(2000)); + pumpUntil([] { return false; }, 10); // settle: nothing more should fire + + CHECK(connectCount.load() == connectCountBefore); + CHECK(disconnectCount.load() == disconnectCountBefore); + CHECK(reconnectCount.load() == reconnectCountBefore); +} + TEST_CASE("morph::qt::QtWebSocketBackend reconnects to a fresh server on the same port", "[qt][ws][lifecycle]") { ensureApp(); morph::exec::ThreadPoolExecutor serverPool{2};