service/ConnectionHealthMonitor.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include <QObject> | ||
| 4 | #include <QNetworkInformation> | ||
| 5 | #include <QString> | ||
| 6 | #include <QTimer> | ||
| 7 | |||
| 8 | #include <functional> | ||
| 9 | |||
| 10 | #include "data/AccountConfig.h" | ||
| 11 | |||
| 12 | class ImapService; | ||
| 13 | class NetworkLifecycleCoordinator; | ||
| 14 | |||
| 15 | // ConnectionHealthMonitor (Sprint 72) wraps a single ImapService with three | ||
| 16 | // responsibilities that previously lived fragmented across MainWindow, | ||
| 17 | // MailController and SettingsSyncService: | ||
| 18 | // | ||
| 19 | // 1. Periodic liveness probes — calls | ||
| 20 | // ImapService::requestLivenessProbe(); ImapService decides whether | ||
| 21 | // that is a NOOP (Authenticated/Selected) or an IDLE DONE/OK | ||
| 22 | // round-trip (Idling) and runs an RTT-aware 3–15 s watchdog. Since | ||
| 23 | // Sprint 92 the primary connection is probed every 7 s (15 s on a | ||
| 24 | // known metered link) and secondaries every 120 s plus before use, so | ||
| 25 | // the main connection detects a dead socket within ~10 s. | ||
| 26 | // 2. Exponential-backoff reconnect — 5, 10, 20, 40, 80, 160, 300, 300, … | ||
| 27 | // seconds (corrected from SettingsSyncService, whose pre-increment | ||
| 28 | // started at 10 s). The Bug-24/T-405 overflow guard is preserved. | ||
| 29 | // Sprint 92 S92.5.4: after a transport failure (socket error, probe or | ||
| 30 | // command timeout, forced recovery) on a reachable network one extra | ||
| 31 | // jittered attempt runs first — 0.5–1.5 s for the primary, 1.5–3 s for | ||
| 32 | // secondaries, so the main connection reconnects first and one outage | ||
| 33 | // never creates a burst. Server rejections (BYE, protocol error) and an | ||
| 34 | // unreachable network start at 5 s. | ||
| 35 | // 3. System suspend/resume + network-change reactivity — on | ||
| 36 | // applicationSuspended → applicationActive and on offline → online / | ||
| 37 | // transport change it calls forceReconnect() so recovery starts in | ||
| 38 | // the first backoff window instead of waiting for a probe timeout. | ||
| 39 | // | ||
| 40 | // The monitor never touches private ImapService internals; it goes through | ||
| 41 | // the two public methods added in T-72.2 (requestLivenessProbe / | ||
| 42 | // abortForReconnect). One monitor instance per ImapService. | ||
| 43 | class ConnectionHealthMonitor : public QObject { | ||
| 44 | Q_OBJECT | ||
| 45 | |||
| 46 | public: | ||
| 47 | // systemWatchEnabled=true (default) marks this monitor as the single | ||
| 48 | // "primary" instance that wires the system-wide suspend + network-change | ||
| 49 | // hooks. Secondary monitors (body/search/sync) pass false so one OS event | ||
| 50 | // does not trigger N simultaneous forceReconnect() calls (Sprint 72 | ||
| 51 | // post-review fix). Secondary monitors still run their own liveness | ||
| 52 | // probes and reconnect on real socket failures / state errors. | ||
| 53 | explicit ConnectionHealthMonitor(bool systemWatchEnabled = true, | ||
| 54 | QObject *parent = nullptr); | ||
| 55 | ~ConnectionHealthMonitor() override; | ||
| 56 | |||
| 57 | // Wire the monitor to an ImapService. Safe to call again after detach(). | ||
| 58 | void attach(ImapService *imap); | ||
| 59 | // Disconnect signal/slot wiring from the previously-attached ImapService. | ||
| 60 | // Call BEFORE the service is destroyed or replaced so monitor slots | ||
| 61 | // cannot react to the dying socket's stateChanged/errorOccurred. | ||
| 62 | void detach(); | ||
| 63 | |||
| 64 | // Account configuration for reconnect attempts. Empty host = invalid; | ||
| 65 | // scheduleReconnect() becomes a no-op until a real config is set. | ||
| 66 | void setReconnectConfig(const ImapConfig &cfg); | ||
| 67 | 1 | ImapConfig reconnectConfig() const { return m_config; } | |
| 68 | |||
| 69 | // false during shutdown / account switch — stops both timers and | ||
| 70 | // suppresses scheduleReconnect(). True re-enables probing in | ||
| 71 | // Authenticated/Selected/Idling. | ||
| 72 | void setActive(bool active); | ||
| 73 | 11 | bool isActive() const { return m_active; } | |
| 74 | // S92.5.7: an outage of this connection is being recovered (a reconnect | ||
| 75 | // is scheduled or the attempt is still connecting). False while healthy, | ||
| 76 | // inactive, intentionally disconnected or waiting for a lifecycle resume. | ||
| 77 | bool isRecovering() const; | ||
| 78 | bool isTransportMediumMetered() const; | ||
| 79 | |||
| 80 | // Probe interval (PROBE_PRIMARY_MS / PROBE_SECONDARY_MS). A shorter interval makes | ||
| 81 | // dead-socket detection faster at the cost of more NOOP traffic. | ||
| 82 | void setProbeInterval(int ms); | ||
| 83 | |||
| 84 | // Force immediate teardown + reconnect (suspend, network change). | ||
| 85 | // Resets backoff, sets the outage flag, and asks the ImapService to | ||
| 86 | // abort — the resulting State::Error fires the monitor's own reconnect | ||
| 87 | // arm with the first 5 s delay. A recovery generation adopts an already | ||
| 88 | // pending/in-progress outage reconnect so a stale socket event delivered | ||
| 89 | // immediately before the OS resume event cannot create a second attempt. | ||
| 90 | void forceReconnect(const QString &reason); | ||
| 91 | void handleRecoveryGeneration(quint64 generation, int trigger); | ||
| 92 | |||
| 93 | // T-72.7 unit-test seams — direct hooks for the suspend/network logic. | ||
| 94 | // Inline so tests do not need a QGuiApplication event loop. | ||
| 95 | // NOTE: only ApplicationSuspended counts as "was suspended". On Linux | ||
| 96 | // desktops ApplicationInactive simply means "window lost focus"; treating | ||
| 97 | // it as resume tore down healthy connections on every focus change | ||
| 98 | // (Sprint 72 post-review fix). | ||
| 99 | void handleApplicationState(Qt::ApplicationState current); | ||
| 100 | 8 | static bool detectTimerSkew(qint64 elapsedMs, qint64 intervalMs) { | |
| 101 | // Treat only a clear multi-minute gap as resume. A busy UI thread can | ||
| 102 | // delay a 15 s timer by 30+ seconds without any suspend/network change. | ||
| 103 |
3/4✓ Branch 2 → 3 taken 4 times.
✓ Branch 2 → 5 taken 4 times.
✓ Branch 3 → 4 taken 4 times.
✗ Branch 3 → 5 not taken.
|
8 | return elapsedMs >= 5 * 60 * 1000 && elapsedMs >= 10 * intervalMs; |
| 104 | } | ||
| 105 | 15 | static bool isReachabilityReconnectTrigger( | |
| 106 | QNetworkInformation::Reachability reachability) { | ||
| 107 |
2/2✓ Branch 3 → 4 taken 10 times.
✓ Branch 3 → 5 taken 2 times.
|
12 | return reachability == QNetworkInformation::Reachability::Local || |
| 108 |
4/4✓ Branch 2 → 3 taken 12 times.
✓ Branch 2 → 5 taken 3 times.
✓ Branch 4 → 5 taken 5 times.
✓ Branch 4 → 6 taken 5 times.
|
27 | reachability == QNetworkInformation::Reachability::Site || |
| 109 | 15 | reachability == QNetworkInformation::Reachability::Online; | |
| 110 | } | ||
| 111 | 7 | static bool isReachabilityReconnectTransition( | |
| 112 | QNetworkInformation::Reachability previous, | ||
| 113 | QNetworkInformation::Reachability current) { | ||
| 114 |
3/4✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 7 taken 4 times.
✓ Branch 5 → 6 taken 3 times.
✗ Branch 5 → 7 not taken.
|
10 | return !isReachabilityReconnectTrigger(previous) && |
| 115 | 10 | isReachabilityReconnectTrigger(current); | |
| 116 | } | ||
| 117 | 6 | static bool isTransportReconnectTransition( | |
| 118 | QNetworkInformation::TransportMedium previous, | ||
| 119 | QNetworkInformation::TransportMedium current) { | ||
| 120 |
2/2✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 6 taken 1 time.
|
4 | return previous != QNetworkInformation::TransportMedium::Unknown && |
| 121 |
4/4✓ Branch 2 → 3 taken 4 times.
✓ Branch 2 → 6 taken 2 times.
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 1 time.
|
10 | current != QNetworkInformation::TransportMedium::Unknown && |
| 122 | 6 | previous != current; | |
| 123 | } | ||
| 124 | |||
| 125 | // S92.5.4 first-attempt window after a transport failure. | ||
| 126 | static constexpr int FAST_RECONNECT_PRIMARY_MIN_MS = 500; | ||
| 127 | static constexpr int FAST_RECONNECT_PRIMARY_MAX_MS = 1500; | ||
| 128 | static constexpr int FAST_RECONNECT_SECONDARY_MIN_MS = 1500; | ||
| 129 | static constexpr int FAST_RECONNECT_SECONDARY_MAX_MS = 3000; | ||
| 130 | |||
| 131 | // Test introspection. | ||
| 132 | 7 | int reconnectAttempts() const { return m_reconnectAttempts; } | |
| 133 | 28 | bool isReconnectScheduled() const { return m_reconnectTimer.isActive(); } | |
| 134 | 9 | int reconnectIntervalSeconds() const { | |
| 135 |
1/2✓ Branch 3 → 4 taken 9 times.
✗ Branch 3 → 6 not taken.
|
9 | return m_reconnectTimer.isActive() ? m_reconnectTimer.interval() / 1000 |
| 136 | 9 | : -1; | |
| 137 | } | ||
| 138 | 8 | int reconnectIntervalMs() const { | |
| 139 |
1/2✓ Branch 3 → 4 taken 8 times.
✗ Branch 3 → 6 not taken.
|
8 | return m_reconnectTimer.isActive() ? m_reconnectTimer.interval() : -1; |
| 140 | } | ||
| 141 | |||
| 142 | // Test/diagnostic state contract (Sprint 92 S92.0.2 replaced the former | ||
| 143 | // test friend declaration, Sprint 91 rule 8). No product caller uses it; | ||
| 144 | // tests drive the monitor's handlers and timers deterministically. | ||
| 145 | 14 | int &mutableReconnectAttempts() { return m_reconnectAttempts; } | |
| 146 | 3 | bool &hadOutage() { return m_hadOutage; } | |
| 147 | 1 | bool &intentionallyDisconnected() { return m_intentionallyDisconnected; } | |
| 148 | 2 | bool &systemWatchEnabled() { return m_systemWatchEnabled; } | |
| 149 | 20 | QTimer &probeTimer() { return m_probeTimer; } | |
| 150 | 4 | QTimer &reconnectTimer() { return m_reconnectTimer; } | |
| 151 | 4 | Qt::ApplicationState &lastAppState() { return m_lastAppState; } | |
| 152 | 7 | qint64 &lastProbeMsecsSinceEpoch() { return m_lastProbeMsecsSinceEpoch; } | |
| 153 | 8 | QNetworkInformation *&networkInformation() { return m_netInfo; } | |
| 154 | 5 | NetworkLifecycleCoordinator *&lifecycle() { return m_lifecycle; } | |
| 155 | 18 | quint64 &lastRecoveryGeneration() { return m_lastRecoveryGeneration; } | |
| 156 | 5 | QNetworkInformation::Reachability &lastReachability() { return m_lastReachability; } | |
| 157 | 7 | QNetworkInformation::TransportMedium &lastTransportMedium() { | |
| 158 | 7 | return m_lastTransportMedium; | |
| 159 | } | ||
| 160 | // S92.5.4: jitter source, returns a value in [minMs, maxMs]. | ||
| 161 | 13 | std::function<int(int minMs, int maxMs)> &jitterSource() { return m_jitter; } | |
| 162 | bool &fastReconnectUsed() { return m_fastReconnectUsed; } | ||
| 163 | void scheduleReconnect(); | ||
| 164 | int backoffDelaySeconds() const; | ||
| 165 | int fastReconnectDelayMs() const; | ||
| 166 | bool fastReconnectAllowed() const; | ||
| 167 | void applyProbeCadence(); | ||
| 168 | |||
| 169 | signals: | ||
| 170 | void statusMessage(const QString &message); | ||
| 171 | void reconnectScheduled(int delaySeconds); | ||
| 172 | void connectionRestored(); | ||
| 173 | // T-87.G2: true on cellular/bluetooth (and when no platform signal is | ||
| 174 | // available, as the deterministic conservative default). | ||
| 175 | void transportMediumMeteredChanged(bool metered); | ||
| 176 | |||
| 177 | // Public only for the test/diagnostic state contract above. | ||
| 178 | public slots: | ||
| 179 | void onConnectionAttemptStarted(); | ||
| 180 | void onIntentionalDisconnect(); | ||
| 181 | void onDisconnectReason(int reason); | ||
| 182 | void onStateChanged(int newState); | ||
| 183 | void onErrorOccurred(const QString &error); | ||
| 184 | void onProbeTimer(); | ||
| 185 | void onReconnect(); | ||
| 186 | |||
| 187 | private: | ||
| 188 | void stopAllTimers(); | ||
| 189 | void setupNetworkHooks(); | ||
| 190 | |||
| 191 | ImapService *m_imap = nullptr; | ||
| 192 | ImapConfig m_config; | ||
| 193 | bool m_active = false; | ||
| 194 | // ctor param: only the primary monitor wires the suspend + network hooks. | ||
| 195 | bool m_systemWatchEnabled = true; | ||
| 196 | bool m_hadOutage = false; | ||
| 197 | bool m_intentionallyDisconnected = false; | ||
| 198 | |||
| 199 | QTimer m_probeTimer; | ||
| 200 | QTimer m_reconnectTimer; | ||
| 201 | int m_reconnectAttempts = 0; | ||
| 202 | // S92.5.4: reason of the last failure (-1: none yet) and the one fast | ||
| 203 | // attempt per outage; the fast attempt does not count as a backoff step. | ||
| 204 | int m_lastDisconnectReason = -1; | ||
| 205 | bool m_fastReconnectUsed = false; | ||
| 206 | bool m_fastAttemptPending = false; | ||
| 207 | std::function<int(int, int)> m_jitter; | ||
| 208 | |||
| 209 | // T-72.7: suspend/resume detection (QGuiApplication::applicationStateChanged). | ||
| 210 | Qt::ApplicationState m_lastAppState = Qt::ApplicationActive; | ||
| 211 | // T-72.7: timer-skew fallback for Linux desktops where | ||
| 212 | // applicationState is unreliable. Wall-clock at the last probe fire. | ||
| 213 | qint64 m_lastProbeMsecsSinceEpoch = 0; | ||
| 214 | |||
| 215 | // T-72.7: optional network-change hooks. Null if no backend available | ||
| 216 | // (e.g. minimal Linux container) — probes + timer-skew still cover us. | ||
| 217 | QNetworkInformation *m_netInfo = nullptr; | ||
| 218 | NetworkLifecycleCoordinator *m_lifecycle = nullptr; | ||
| 219 | quint64 m_lastRecoveryGeneration = 0; | ||
| 220 | QNetworkInformation::Reachability m_lastReachability = | ||
| 221 | QNetworkInformation::Reachability::Unknown; | ||
| 222 | QNetworkInformation::TransportMedium m_lastTransportMedium = | ||
| 223 | QNetworkInformation::TransportMedium::Unknown; | ||
| 224 | |||
| 225 | static constexpr int MAX_RECONNECT_DELAY = 300; // 5 min cap (Bug-24 guard) | ||
| 226 | static bool transportMediumIsMetered(QNetworkInformation::TransportMedium medium); | ||
| 227 | // Probe cadence (Sprint 92 S92.5.2/S92.5.3): the primary connection every | ||
| 228 | // 7 s (with the RTT-aware 3–15 s watchdog: detection p95 below 10 s), 15 s | ||
| 229 | // on a known metered link, secondaries every 120 s plus a probe before a | ||
| 230 | // user-visible command (ImapService probe-before-use). | ||
| 231 | static constexpr int PROBE_PRIMARY_MS = 7000; | ||
| 232 | static constexpr int PROBE_METERED_MS = 15000; | ||
| 233 | static constexpr int PROBE_SECONDARY_MS = 120000; | ||
| 234 | }; | ||
| 235 |