MailJDTourDownloadDocsQualityTestsCoverage

Which source lines and branches the tests execute. Lines 91.5 %, branches 75.0 % (minimum 85 % and 72 %). main @ 399ffe3d · 21 October 2026 · pipeline 7238.

GCC Code Coverage Report


Directory: src/
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 94.3% 330 / 0 / 350
Functions: 97.1% 34 / 0 / 35
Branches: 89.7% 306 / 54 / 395

service/ConnectionHealthMonitor.cpp
Line Branch Exec Source
1 #include "ConnectionHealthMonitor.h"
2
3 #include <QLoggingCategory>
4 #include <QNetworkInformation>
5 #include <QDateTime>
6 #include <QRandomGenerator>
7
8 #include "service/ImapService.h"
9 #include "service/NetworkLifecycleCoordinator.h"
10
11 #ifdef QT_GUI_LIB
12 #include <QGuiApplication>
13 #endif
14
15
4/7
✓ Branch 2 → 3 taken 38 times.
✓ Branch 2 → 8 taken 1218 times.
✓ Branch 4 → 5 taken 38 times.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 38 times.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
1256 Q_LOGGING_CATEGORY(lcHealth, "mailjd.health")
16
17 971 ConnectionHealthMonitor::ConnectionHealthMonitor(bool systemWatchEnabled,
18 971 QObject *parent)
19
3/3
✓ Branch 3 → 4 taken 971 times.
✓ Branch 4 → 5 taken 971 times.
✓ Branch 5 → 6 taken 971 times.
971 : QObject(parent), m_systemWatchEnabled(systemWatchEnabled) {
20
1/1
✓ Branch 7 → 8 taken 971 times.
971 m_probeTimer.setSingleShot(true);
21 // S92.5.2/S92.5.3: the primary (main) connection carries push and is
22 // probed often; secondaries are checked before use (probeBeforeUse).
23
3/3
✓ Branch 8 → 9 taken 881 times.
✓ Branch 8 → 10 taken 90 times.
✓ Branch 11 → 12 taken 971 times.
971 m_probeTimer.setInterval(m_systemWatchEnabled ? PROBE_PRIMARY_MS : PROBE_SECONDARY_MS);
24 987 m_jitter = [](int minMs, int maxMs) {
25 16 return int(QRandomGenerator::global()->bounded(minMs, maxMs + 1));
26 971 };
27 971 connect(&m_probeTimer, &QTimer::timeout, this,
28
1/1
✓ Branch 13 → 14 taken 971 times.
971 &ConnectionHealthMonitor::onProbeTimer);
29
30
1/1
✓ Branch 15 → 16 taken 971 times.
971 m_reconnectTimer.setSingleShot(true);
31 971 connect(&m_reconnectTimer, &QTimer::timeout, this,
32
1/1
✓ Branch 16 → 17 taken 971 times.
971 &ConnectionHealthMonitor::onReconnect);
33
34
1/1
✓ Branch 18 → 19 taken 971 times.
971 m_lifecycle = NetworkLifecycleCoordinator::instance();
35 971 connect(m_lifecycle, &NetworkLifecycleCoordinator::suspendingChanged, this,
36
1/1
✓ Branch 19 → 20 taken 971 times.
971 [this](bool suspending) {
37
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 1 time.
2 if (suspending)
38 1 stopAllTimers();
39 2 });
40 971 connect(m_lifecycle, &NetworkLifecycleCoordinator::recoveryRequested, this,
41
1/1
✓ Branch 21 → 22 taken 971 times.
971 [this](quint64 generation,
42 NetworkLifecycleCoordinator::Trigger trigger) {
43 24 handleRecoveryGeneration(generation, static_cast<int>(trigger));
44 24 });
45
46 // System-wide hooks (suspend + network change) are owned by a single
47 // "primary" monitor only — otherwise one OS event would fan out into N
48 // simultaneous forceReconnect() calls (one per secondary connection).
49
2/2
✓ Branch 23 → 24 taken 90 times.
✓ Branch 23 → 35 taken 881 times.
971 if (!m_systemWatchEnabled) {
50
5/5
✓ Branch 24 → 25 taken 90 times.
✓ Branch 28 → 29 taken 90 times.
✓ Branch 29 → 30 taken 90 times.
✓ Branch 33 → 26 taken 90 times.
✓ Branch 33 → 34 taken 90 times.
180 qCInfo(lcHealth) << "Secondary monitor — system-watch hooks disabled";
51 90 return;
52 }
53
54 // T-72.7: Suspend/resume hook. applicationStateChanged is emitted by
55 // the platform plugin (Windows, macOS, mobile). On Linux desktops it is
56 // unreliable — Inactive simply means "window lost focus", not suspend —
57 // so we do not wire it there at all. The timer-skew fallback in
58 // onProbeTimer() is the resume detector on Linux.
59 #if defined(QT_GUI_LIB) && !defined(Q_OS_LINUX)
60 if (auto *app = qobject_cast<QGuiApplication *>(QCoreApplication::instance())) {
61 connect(app, &QGuiApplication::applicationStateChanged, this,
62 [this](Qt::ApplicationState s) { handleApplicationState(s); });
63 m_lastAppState = app->applicationState();
64 }
65 #endif
66
67
1/1
✓ Branch 35 → 36 taken 881 times.
881 setupNetworkHooks();
68 ✗ }
69
70 1904 ConnectionHealthMonitor::~ConnectionHealthMonitor() {
71 971 stopAllTimers();
72 971 detach();
73 1904 }
74
75 881 void ConnectionHealthMonitor::setupNetworkHooks() {
76 // T-72.7: QNetworkInformation is part of Qt Network (already linked as
77 // Qt6::Network per the sprint plan). Try the default backend; if none
78 // is available (minimal container), skip gracefully — probes + timer
79 // skew still detect dead sockets.
80
2/3
✓ Branch 2 → 3 taken 881 times.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 18 taken 881 times.
881 if (QNetworkInformation::availableBackends().isEmpty()) {
81 ✗ qCInfo(lcHealth) << "No QNetworkInformation backend — relying on probes" // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; backend result remains measured.
82 ✗ << "and timer-skew fallback for resume detection";
83 ✗ return;
84 }
85
1/2
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 31 taken 881 times.
881 if (!QNetworkInformation::loadDefaultBackend()) {
86 ✗ qCWarning(lcHealth) << "QNetworkInformation::loadDefaultBackend() failed"; // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; backend result remains measured.
87 ✗ return;
88 }
89 881 m_netInfo = QNetworkInformation::instance();
90
1/2
✗ Branch 32 → 33 not taken.
✓ Branch 32 → 44 taken 881 times.
881 if (!m_netInfo) {
91 ✗ qCWarning(lcHealth) << "QNetworkInformation::instance() returned null"; // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; instance guard remains measured.
92 ✗ return;
93 }
94 881 m_lastReachability = m_netInfo->reachability();
95 881 m_lastTransportMedium = m_netInfo->transportMedium();
96 881 applyProbeCadence();
97 881 connect(m_netInfo, &QNetworkInformation::reachabilityChanged, this,
98
1/1
✓ Branch 47 → 48 taken 881 times.
881 [this](QNetworkInformation::Reachability r) {
99 3 const auto previous = m_lastReachability;
100 3 m_lastReachability = r;
101
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 2 times.
3 if (!m_active)
102 1 return;
103 // Only Disconnected/Unknown → reachable is actionable. Some Qt
104 // backends emit initial or duplicate reachable signals shortly
105 // after startup; those must not tear down a healthy IMAP session.
106
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 27 taken 1 time.
2 if (isReachabilityReconnectTransition(previous, r)) {
107 3 qCInfo(lcHealth) << "Network reachability:" << previous << "→" // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; state-change decision remains measured.
108
2/2
✓ Branch 16 → 17 taken 1 time.
✓ Branch 17 → 18 taken 1 time.
2 << r << "— forcing reconnect";
109 1 m_lifecycle->requestRecovery(
110 NetworkLifecycleCoordinator::Trigger::Reachability);
111 }
112 });
113 881 connect(m_netInfo, &QNetworkInformation::transportMediumChanged, this,
114
1/1
✓ Branch 49 → 50 taken 881 times.
881 [this](QNetworkInformation::TransportMedium m) {
115 3 const auto previous = m_lastTransportMedium;
116 3 m_lastTransportMedium = m;
117 // Metered state is an environment fact: it matters even while
118 // this monitor is inactive (T-87.G2 speculation policy).
119 3 emit transportMediumMeteredChanged(transportMediumIsMetered(m));
120 3 applyProbeCadence();
121
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 2 times.
3 if (!m_active)
122 1 return;
123 // Wi-Fi ↔ cellular/carrier changes almost always mean a NAT reset.
124 // Ignore Unknown and initial backend-settling signals.
125
2/2
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 30 taken 1 time.
2 if (isTransportReconnectTransition(previous, m)) {
126 3 qCInfo(lcHealth) << "Transport medium changed (" << previous // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; state-change decision remains measured.
127
3/3
✓ Branch 17 → 18 taken 1 time.
✓ Branch 19 → 20 taken 1 time.
✓ Branch 20 → 21 taken 1 time.
2 << "→" << m << ") — forcing reconnect";
128 1 m_lifecycle->requestRecovery(
129 NetworkLifecycleCoordinator::Trigger::NetworkIdentity);
130 }
131 });
132
5/5
✓ Branch 51 → 52 taken 881 times.
✓ Branch 55 → 56 taken 881 times.
✓ Branch 56 → 57 taken 881 times.
✓ Branch 63 → 53 taken 881 times.
✓ Branch 63 → 64 taken 881 times.
1762 qCInfo(lcHealth) << "Network-change hooks active via"
133
2/2
✓ Branch 57 → 58 taken 881 times.
✓ Branch 58 → 59 taken 881 times.
881 << m_netInfo->backendName();
134 }
135
136 970 void ConnectionHealthMonitor::attach(ImapService *imap) {
137
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 969 times.
970 if (m_imap == imap)
138 1 return;
139 969 detach();
140 969 m_imap = imap;
141
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 968 times.
969 if (!m_imap)
142 1 return;
143 // Wire through a Qt::QueuedConnection-friendly int slot so the monitor
144 // does not depend on the ImapService enum meta-type at connect time.
145 968 connect(m_imap, &ImapService::stateChanged, this,
146
1/1
✓ Branch 7 → 8 taken 968 times.
1214 [this](ImapService::State s) { onStateChanged(static_cast<int>(s)); });
147 968 connect(m_imap, &ImapService::errorOccurred, this,
148
1/1
✓ Branch 9 → 10 taken 968 times.
968 &ConnectionHealthMonitor::onErrorOccurred);
149 968 connect(m_imap, &ImapService::connectionAttemptStarted, this,
150
1/1
✓ Branch 11 → 12 taken 968 times.
968 &ConnectionHealthMonitor::onConnectionAttemptStarted);
151 968 connect(m_imap, &ImapService::intentionalDisconnect, this,
152
1/1
✓ Branch 13 → 14 taken 968 times.
968 &ConnectionHealthMonitor::onIntentionalDisconnect);
153 968 connect(m_imap, &ImapService::disconnectReasonChanged, this,
154
1/1
✓ Branch 15 → 16 taken 968 times.
1936 [this](ImapService::DisconnectReason reason) {
155 911 onDisconnectReason(static_cast<int>(reason));
156 911 });
157 }
158
159 2878 void ConnectionHealthMonitor::detach() {
160
2/2
✓ Branch 2 → 3 taken 968 times.
✓ Branch 2 → 4 taken 1910 times.
2878 if (m_imap)
161 968 QObject::disconnect(m_imap, nullptr, this, nullptr);
162 2878 m_imap = nullptr;
163 2878 stopAllTimers();
164 2878 }
165
166 100 void ConnectionHealthMonitor::setReconnectConfig(const ImapConfig &cfg) {
167 100 m_config = cfg;
168
1/2
✓ Branch 3 → 4 taken 100 times.
✗ Branch 3 → 5 not taken.
100 if (m_lifecycle)
169 100 m_lifecycle->observeEndpoint(cfg.host);
170 100 }
171
172 1940 void ConnectionHealthMonitor::setActive(bool active) {
173
2/2
✓ Branch 2 → 3 taken 1787 times.
✓ Branch 2 → 4 taken 153 times.
1940 if (m_active == active)
174 1787 return;
175 153 m_active = active;
176
7/7
✓ Branch 4 → 5 taken 153 times.
✓ Branch 8 → 9 taken 153 times.
✓ Branch 9 → 10 taken 96 times.
✓ Branch 9 → 11 taken 57 times.
✓ Branch 12 → 13 taken 153 times.
✓ Branch 16 → 6 taken 153 times.
✓ Branch 16 → 17 taken 153 times.
306 qCInfo(lcHealth) << (active ? "Monitor activated" : "Monitor deactivated");
177
2/2
✓ Branch 17 → 18 taken 57 times.
✓ Branch 17 → 20 taken 96 times.
153 if (!active) {
178 57 stopAllTimers();
179 57 m_hadOutage = false;
180
2/2
✓ Branch 20 → 21 taken 93 times.
✓ Branch 20 → 26 taken 3 times.
96 } else if (m_imap) {
181 // Explicit activation is a new user-driven connection lifecycle —
182 // S106-010: including the backoff. The counters of an earlier outage
183 // made the first retry of the new lifecycle wait 160–300 s.
184 93 m_intentionallyDisconnected = false;
185 93 m_reconnectAttempts = 0;
186 93 m_fastReconnectUsed = false;
187 93 m_fastAttemptPending = false;
188 // Re-arm the probe timer if currently in a probeable state.
189 93 ImapService::State s = m_imap->state();
190
4/4
✓ Branch 22 → 23 taken 89 times.
✓ Branch 22 → 25 taken 4 times.
✓ Branch 23 → 24 taken 80 times.
✓ Branch 23 → 25 taken 9 times.
93 if (s == ImapService::State::Authenticated ||
191
2/2
✓ Branch 24 → 25 taken 1 time.
✓ Branch 24 → 26 taken 79 times.
80 s == ImapService::State::Selected ||
192 s == ImapService::State::Idling) {
193 14 m_probeTimer.start();
194 }
195 }
196 }
197
198 40 void ConnectionHealthMonitor::onConnectionAttemptStarted() {
199 40 m_intentionallyDisconnected = false;
200 40 }
201
202 871 void ConnectionHealthMonitor::onIntentionalDisconnect() {
203 871 m_intentionallyDisconnected = true;
204 871 m_hadOutage = false;
205 871 stopAllTimers();
206 871 }
207
208 911 void ConnectionHealthMonitor::onDisconnectReason(int reasonInt) {
209 911 const auto reason =
210 static_cast<ImapService::DisconnectReason>(reasonInt);
211 911 m_lastDisconnectReason = reasonInt;
212 911 const bool suppressReconnect =
213
2/2
✓ Branch 3 → 4 taken 39 times.
✓ Branch 3 → 7 taken 869 times.
908 reason == ImapService::DisconnectReason::UserRequested ||
214
2/2
✓ Branch 4 → 5 taken 38 times.
✓ Branch 4 → 7 taken 1 time.
39 reason == ImapService::DisconnectReason::AccountReload ||
215
2/2
✓ Branch 5 → 6 taken 37 times.
✓ Branch 5 → 7 taken 1 time.
38 reason == ImapService::DisconnectReason::ApplicationShutdown ||
216
4/4
✓ Branch 2 → 3 taken 908 times.
✓ Branch 2 → 7 taken 3 times.
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 36 times.
1819 reason == ImapService::DisconnectReason::AuthenticationRejected ||
217 reason == ImapService::DisconnectReason::TlsPolicyRejected;
218 911 m_intentionallyDisconnected = suppressReconnect;
219
2/2
✓ Branch 9 → 10 taken 875 times.
✓ Branch 9 → 11 taken 36 times.
911 if (suppressReconnect)
220 875 stopAllTimers();
221 911 }
222
223 1 void ConnectionHealthMonitor::setProbeInterval(int ms) {
224 1 m_probeTimer.setInterval(ms);
225 1 }
226
227 21 void ConnectionHealthMonitor::forceReconnect(const QString &reason) {
228
2/2
✓ Branch 2 → 3 taken 3 times.
✓ Branch 2 → 16 taken 18 times.
21 if (!m_imap) {
229 6 qCWarning(lcHealth) << "forceReconnect requested but no ImapService" // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; service guard remains measured.
230
2/2
✓ Branch 9 → 10 taken 3 times.
✓ Branch 10 → 11 taken 3 times.
3 << "attached — reason:" << reason;
231 3 return;
232 }
233
6/6
✓ Branch 16 → 17 taken 18 times.
✓ Branch 20 → 21 taken 18 times.
✓ Branch 21 → 22 taken 18 times.
✓ Branch 22 → 23 taken 18 times.
✓ Branch 26 → 18 taken 18 times.
✓ Branch 26 → 27 taken 18 times.
36 qCInfo(lcHealth) << "forceReconnect:" << reason;
234 // Reset backoff so resume yields near-immediate recovery rather than
235 // waiting for a probe timeout. Set the outage flag so the next
236 // Authenticated emits connectionRestored().
237 18 m_reconnectAttempts = 0;
238 18 m_fastReconnectUsed = false;
239 18 m_fastAttemptPending = false;
240 18 m_reconnectTimer.stop();
241 18 m_hadOutage = true;
242
2/2
✓ Branch 28 → 29 taken 17 times.
✓ Branch 28 → 30 taken 1 time.
18 if (m_active)
243 17 m_imap->abortForReconnect(reason);
244 }
245
246 252 void ConnectionHealthMonitor::onStateChanged(int newStateInt) {
247
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 251 times.
252 if (!m_imap)
248 1 return;
249 251 auto newState = static_cast<ImapService::State>(newStateInt);
250
251
3/4
✓ Branch 4 → 5 taken 64 times.
✗ Branch 4 → 25 not taken.
✓ Branch 4 → 29 taken 56 times.
✓ Branch 4 → 33 taken 131 times.
251 switch (newState) {
252 64 case ImapService::State::Authenticated:
253 case ImapService::State::Selected:
254 // (Re)start the probe timer — these are the states we probe.
255
2/2
✓ Branch 5 → 6 taken 58 times.
✓ Branch 5 → 24 taken 6 times.
64 if (m_active) {
256 58 m_reconnectAttempts = 0;
257 58 m_fastReconnectUsed = false;
258 58 m_fastAttemptPending = false;
259 58 m_lastDisconnectReason = -1;
260 58 m_reconnectTimer.stop();
261 58 m_probeTimer.start(m_probeTimer.interval());
262 58 m_lastProbeMsecsSinceEpoch = QDateTime::currentMSecsSinceEpoch();
263
1/2
✓ Branch 10 → 11 taken 58 times.
✗ Branch 10 → 12 not taken.
58 if (m_lifecycle)
264 58 m_lifecycle->markStable(m_lastRecoveryGeneration);
265
2/2
✓ Branch 12 → 13 taken 12 times.
✓ Branch 12 → 24 taken 46 times.
58 if (m_hadOutage) {
266
5/5
✓ Branch 13 → 14 taken 12 times.
✓ Branch 17 → 18 taken 12 times.
✓ Branch 18 → 19 taken 12 times.
✓ Branch 22 → 15 taken 12 times.
✓ Branch 22 → 23 taken 12 times.
24 qCInfo(lcHealth) << "Connection restored after outage";
267 12 m_hadOutage = false;
268 12 emit connectionRestored();
269 }
270 }
271 64 break;
272 ✗ case ImapService::State::Idling:
273 // Still healthy — keep probing (probe will issue IDLE DONE/OK).
274 ✗ if (m_active)
275 ✗ m_probeTimer.start(m_probeTimer.interval());
276 ✗ break;
277 56 case ImapService::State::Error:
278 case ImapService::State::Disconnected:
279 56 m_probeTimer.stop();
280
2/2
✓ Branch 30 → 31 taken 47 times.
✓ Branch 30 → 32 taken 9 times.
56 if (!m_intentionallyDisconnected)
281 47 scheduleReconnect();
282 56 break;
283 131 default:
284 // Connecting/Connected/Greeting/Capability/StartingTLS/Authenticating:
285 // no probe needed, no reconnect scheduled.
286 131 break;
287 }
288 }
289
290 31 void ConnectionHealthMonitor::onErrorOccurred(const QString &error) {
291
6/6
✓ Branch 2 → 3 taken 31 times.
✓ Branch 6 → 7 taken 31 times.
✓ Branch 7 → 8 taken 31 times.
✓ Branch 8 → 9 taken 31 times.
✓ Branch 12 → 4 taken 31 times.
✓ Branch 12 → 13 taken 31 times.
62 qCWarning(lcHealth) << "IMAP error:" << error;
292 // Idempotent — if stateChanged(Error) already armed the timer, this is
293 // a no-op. We DO NOT reset backoff here; only Authenticated does that.
294
2/2
✓ Branch 13 → 14 taken 28 times.
✓ Branch 13 → 15 taken 3 times.
31 if (!m_intentionallyDisconnected)
295 28 scheduleReconnect();
296 31 }
297
298 9 void ConnectionHealthMonitor::onProbeTimer() {
299
3/4
✓ Branch 2 → 3 taken 8 times.
✓ Branch 2 → 4 taken 1 time.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 8 times.
9 if (!m_imap || !m_active)
300 1 return;
301
302 // T-72.7: timer-skew fallback. applicationStateChanged is unreliable
303 // on Linux desktops; the kernel stops dispatching timers during
304 // suspend, so the probe fires very late when the laptop wakes up.
305 8 const auto now = QDateTime::currentMSecsSinceEpoch();
306
2/2
✓ Branch 6 → 7 taken 3 times.
✓ Branch 6 → 26 taken 5 times.
8 if (m_lastProbeMsecsSinceEpoch > 0) {
307 3 const qint64 elapsed = now - m_lastProbeMsecsSinceEpoch;
308
2/2
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 26 taken 1 time.
3 if (detectTimerSkew(elapsed, m_probeTimer.interval())) {
309 4 qCInfo(lcHealth) << "Probe fired" << elapsed / 1000 // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; probe threshold remains measured.
310
1/1
✓ Branch 17 → 18 taken 2 times.
2 << "s late — treating as resume";
311 2 m_lastProbeMsecsSinceEpoch = now;
312 // S106-009: keep probing. The coordinator may coalesce this request
313 // into a recovery window that ends without a reconnect for this
314 // monitor; without a re-armed timer the connection then had neither a
315 // probe nor a reconnect until some external trigger.
316 2 m_probeTimer.start(m_probeTimer.interval());
317 2 m_lifecycle->requestRecovery(
318 NetworkLifecycleCoordinator::Trigger::TimerSkew);
319 2 return;
320 }
321 }
322 6 m_lastProbeMsecsSinceEpoch = now;
323
324 // Ask ImapService for a protocol-aware probe (NOOP or IDLE DONE/OK).
325 // The service's own 15 s watchdog is the second half of the detection
326 // budget; if it fires, failConnection() transitions to Error and our
327 // stateChanged arm schedules the reconnect.
328
1/1
✓ Branch 29 → 30 taken 6 times.
12 m_imap->requestLivenessProbe(QStringLiteral("periodic health check"));
329
330 // Restart for the next cycle (only meaningful in probeable states).
331 6 ImapService::State s = m_imap->state();
332
4/4
✓ Branch 33 → 34 taken 4 times.
✓ Branch 33 → 36 taken 2 times.
✓ Branch 34 → 35 taken 3 times.
✓ Branch 34 → 36 taken 1 time.
6 if (s == ImapService::State::Authenticated ||
333
2/2
✓ Branch 35 → 36 taken 1 time.
✓ Branch 35 → 38 taken 2 times.
3 s == ImapService::State::Selected ||
334 s == ImapService::State::Idling) {
335 4 m_probeTimer.start(m_probeTimer.interval());
336 }
337 }
338
339 5 void ConnectionHealthMonitor::onReconnect() {
340
3/4
✓ Branch 2 → 3 taken 4 times.
✓ Branch 2 → 4 taken 1 time.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 15 taken 4 times.
5 if (!m_active || !m_imap) {
341 2 qCDebug(lcHealth) << "onReconnect: inactive or no ImapService — skip"; // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; reconnect guard remains measured.
342 1 return;
343 }
344
2/2
✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 28 taken 3 times.
4 if (m_config.host.isEmpty()) {
345 2 qCWarning(lcHealth) << "onReconnect: empty config — skip"; // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; config guard remains measured.
346 1 return;
347 }
348
6/6
✓ Branch 28 → 29 taken 3 times.
✓ Branch 32 → 33 taken 3 times.
✓ Branch 33 → 34 taken 3 times.
✓ Branch 34 → 35 taken 3 times.
✓ Branch 39 → 30 taken 3 times.
✓ Branch 39 → 40 taken 3 times.
6 qCInfo(lcHealth) << "Reconnecting (attempt" << (m_reconnectAttempts + 1)
349
1/1
✓ Branch 35 → 36 taken 3 times.
3 << ")…";
350
1/1
✓ Branch 43 → 44 taken 3 times.
3 emit statusMessage(QStringLiteral("Reconnecting…"));
351 // ImapService::connectToServer() validates that state is Disconnected
352 // or Error before connecting. abortForReconnect()/failConnection() has
353 // already transitioned us there.
354 3 m_imap->connectToServer(m_config);
355 // Increment AFTER the attempt so the first delay is the base 5 s,
356 // not 10 s (the off-by-one that SettingsSyncService had). The S92.5.4
357 // fast attempt is extra: the backoff after it still starts at 5 s.
358
2/2
✓ Branch 47 → 48 taken 2 times.
✓ Branch 47 → 49 taken 1 time.
3 if (m_fastAttemptPending)
359 2 m_fastAttemptPending = false;
360 else
361 1 ++m_reconnectAttempts;
362 }
363
364 77 void ConnectionHealthMonitor::scheduleReconnect() {
365
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 14 taken 76 times.
77 if (m_intentionallyDisconnected) {
366 2 qCDebug(lcHealth) << "scheduleReconnect: intentional disconnect — skip"; // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; disconnect guard remains measured.
367 1 return;
368 }
369
2/2
✓ Branch 14 → 15 taken 2 times.
✓ Branch 14 → 26 taken 74 times.
76 if (!m_active) {
370
5/5
✓ Branch 15 → 16 taken 2 times.
✓ Branch 19 → 20 taken 2 times.
✓ Branch 20 → 21 taken 2 times.
✓ Branch 24 → 17 taken 2 times.
✓ Branch 24 → 25 taken 2 times.
4 qCDebug(lcHealth) << "scheduleReconnect: inactive — skip";
371 2 return;
372 }
373
5/6
✓ Branch 26 → 27 taken 74 times.
✗ Branch 26 → 30 not taken.
✓ Branch 28 → 29 taken 2 times.
✓ Branch 28 → 30 taken 72 times.
✓ Branch 31 → 32 taken 2 times.
✓ Branch 31 → 43 taken 72 times.
74 if (m_lifecycle && m_lifecycle->isSuspending()) {
374 // Socket teardown can arrive after PrepareForSleep(true). Preserve the
375 // outage fact, but do not arm a timer that can wake and reconnect while
376 // the process is still in its bounded pre-sleep phase. Native resume will
377 // fan out one recovery generation to every active transport.
378 2 m_hadOutage = true;
379
5/5
✓ Branch 32 → 33 taken 2 times.
✓ Branch 36 → 37 taken 2 times.
✓ Branch 37 → 38 taken 2 times.
✓ Branch 41 → 34 taken 2 times.
✓ Branch 41 → 42 taken 2 times.
4 qCInfo(lcHealth) << "Deferring reconnect until lifecycle resume";
380 2 return;
381 }
382
2/2
✓ Branch 44 → 45 taken 3 times.
✓ Branch 44 → 56 taken 69 times.
72 if (m_config.host.isEmpty()) {
383
5/5
✓ Branch 45 → 46 taken 3 times.
✓ Branch 49 → 50 taken 3 times.
✓ Branch 50 → 51 taken 3 times.
✓ Branch 54 → 47 taken 3 times.
✓ Branch 54 → 55 taken 3 times.
6 qCDebug(lcHealth) << "scheduleReconnect: empty config — skip";
384 3 return;
385 }
386 // Idempotent: a second error while the timer is already armed does
387 // NOT shorten or restart the pending reconnect. This avoids the
388 // "staircase" of overlapping single-shots the previous implementations
389 // risked.
390
2/2
✓ Branch 57 → 58 taken 24 times.
✓ Branch 57 → 59 taken 45 times.
69 if (m_reconnectTimer.isActive())
391 24 return;
392
393 45 m_hadOutage = true;
394
2/2
✓ Branch 60 → 61 taken 25 times.
✓ Branch 60 → 86 taken 20 times.
45 if (fastReconnectAllowed()) {
395 25 const int delayMs = fastReconnectDelayMs();
396 25 m_fastReconnectUsed = true;
397 25 m_fastAttemptPending = true;
398
6/6
✓ Branch 62 → 63 taken 25 times.
✓ Branch 66 → 67 taken 25 times.
✓ Branch 67 → 68 taken 25 times.
✓ Branch 68 → 69 taken 25 times.
✓ Branch 78 → 64 taken 25 times.
✓ Branch 78 → 79 taken 25 times.
75 qCInfo(lcHealth) << "Scheduling fast reconnect in" << delayMs
399
1/1
✓ Branch 69 → 70 taken 25 times.
25 << "ms after transport failure ("
400
1/1
✓ Branch 71 → 72 taken 25 times.
75 << static_cast<ImapService::DisconnectReason>(m_lastDisconnectReason)
401
1/1
✓ Branch 72 → 73 taken 25 times.
25 << ")";
402 25 emit reconnectScheduled((delayMs + 999) / 1000);
403 25 m_reconnectTimer.start(delayMs);
404 // S92.5.6: a silent timeout of the primary means the network path is
405 // dead: one recovery generation lets every other connection of the
406 // process verify itself (this monitor adopts it, its reconnect is armed).
407
3/4
✓ Branch 81 → 82 taken 11 times.
✓ Branch 81 → 85 taken 14 times.
✓ Branch 82 → 83 taken 11 times.
✗ Branch 82 → 85 not taken.
25 if (m_systemWatchEnabled && m_lifecycle &&
408
2/2
✓ Branch 83 → 84 taken 2 times.
✓ Branch 83 → 85 taken 9 times.
11 static_cast<ImapService::DisconnectReason>(m_lastDisconnectReason) ==
409 ImapService::DisconnectReason::CommandTimeout)
410 2 m_lifecycle->requestRecovery(NetworkLifecycleCoordinator::Trigger::SocketFailure);
411 25 return;
412 }
413 20 const int delay = backoffDelaySeconds();
414
6/6
✓ Branch 87 → 88 taken 20 times.
✓ Branch 91 → 92 taken 20 times.
✓ Branch 92 → 93 taken 20 times.
✓ Branch 93 → 94 taken 20 times.
✓ Branch 100 → 89 taken 20 times.
✓ Branch 100 → 101 taken 20 times.
40 qCInfo(lcHealth) << "Scheduling reconnect in" << delay
415
3/3
✓ Branch 94 → 95 taken 20 times.
✓ Branch 95 → 96 taken 20 times.
✓ Branch 96 → 97 taken 20 times.
20 << "s (attempt" << (m_reconnectAttempts + 1) << ")";
416
2/2
✓ Branch 105 → 106 taken 20 times.
✓ Branch 106 → 107 taken 20 times.
40 emit statusMessage(QStringLiteral("Reconnect in %1s…").arg(delay));
417 20 emit reconnectScheduled(delay);
418 20 m_reconnectTimer.start(delay * 1000);
419 }
420
421 2459 bool ConnectionHealthMonitor::isRecovering() const {
422
6/8
✓ Branch 2 → 3 taken 32 times.
✓ Branch 2 → 6 taken 2427 times.
✓ Branch 3 → 4 taken 8 times.
✓ Branch 3 → 6 taken 24 times.
✓ Branch 4 → 5 taken 8 times.
✗ Branch 4 → 6 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 8 times.
2459 if (!m_active || !m_hadOutage || m_intentionallyDisconnected || !m_imap)
423 2451 return false;
424
1/2
✓ Branch 8 → 9 taken 8 times.
✗ Branch 8 → 10 not taken.
8 if (m_reconnectTimer.isActive())
425 8 return true;
426 ✗ switch (m_imap->state()) {
427 ✗ case ImapService::State::Connecting:
428 case ImapService::State::Connected:
429 case ImapService::State::Greeting:
430 case ImapService::State::Capability:
431 case ImapService::State::StartingTLS:
432 case ImapService::State::Authenticating:
433 ✗ return true;
434 ✗ default:
435 ✗ return false;
436 }
437 }
438
439 // S92.5.4: one fast attempt per outage, only for a failure of the transport
440 // (the server itself did not refuse us) while the network is reachable.
441 45 bool ConnectionHealthMonitor::fastReconnectAllowed() const {
442
3/4
✓ Branch 2 → 3 taken 42 times.
✓ Branch 2 → 4 taken 3 times.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 42 times.
45 if (m_fastReconnectUsed || m_reconnectAttempts != 0)
443 3 return false;
444
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 41 times.
42 if (m_lastReachability == QNetworkInformation::Reachability::Disconnected)
445 1 return false;
446 using Reason = ImapService::DisconnectReason;
447
2/2
✓ Branch 7 → 8 taken 25 times.
✓ Branch 7 → 9 taken 16 times.
41 switch (static_cast<Reason>(m_lastDisconnectReason)) {
448 25 case Reason::SocketError:
449 case Reason::CommandTimeout:
450 case Reason::ForcedHealthProbe:
451 25 return true;
452 16 default:
453 16 return false;
454 }
455 }
456
457 // Primary first, secondaries after it: one outage of an account produces a
458 // staggered sequence of attempts instead of a burst.
459 25 int ConnectionHealthMonitor::fastReconnectDelayMs() const {
460 25 return m_systemWatchEnabled
461
2/2
✓ Branch 2 → 3 taken 11 times.
✓ Branch 2 → 5 taken 14 times.
25 ? m_jitter(FAST_RECONNECT_PRIMARY_MIN_MS, FAST_RECONNECT_PRIMARY_MAX_MS)
462 25 : m_jitter(FAST_RECONNECT_SECONDARY_MIN_MS, FAST_RECONNECT_SECONDARY_MAX_MS);
463 }
464
465 32 int ConnectionHealthMonitor::backoffDelaySeconds() const {
466 // Corrected from SettingsSyncService: NO pre-increment, so the first
467 // delay is the base 5 s. delay = 5 * 2^min(attemptIndex, 20) capped at
468 // 300 s → 5, 10, 20, 40, 80, 160, 300, 300, …
469 // Bug-24/T-405: cap the shift to 20 to prevent integer overflow UB
470 // when m_reconnectAttempts >= 31.
471 32 const int capped = std::min(m_reconnectAttempts, 20);
472 32 return std::min(5 * (1 << capped), MAX_RECONNECT_DELAY);
473 }
474
475 5653 void ConnectionHealthMonitor::stopAllTimers() {
476 5653 m_probeTimer.stop();
477 5653 m_reconnectTimer.stop();
478 5653 }
479
480 5 void ConnectionHealthMonitor::handleApplicationState(
481 Qt::ApplicationState current) {
482
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 4 times.
5 if (!m_active)
483 1 return;
484 // Suspended → Active = "the system just woke up". A network roaming
485 // event almost certainly accompanied the resume, so force a reconnect
486 // instead of waiting for a probe timeout.
487 //
488 // Sprint 72 post-review fix: ApplicationInactive must NOT be treated as
489 // "suspended". On Linux desktops (XCB/Wayland) and even on Windows/macOS
490 // when another window takes focus, Inactive simply means "not the active
491 // window". Treating it as resume tore down healthy IMAP connections on
492 // every focus change. Only the genuine Suspended state (mobile
493 // backgrounding / system sleep) is actionable.
494 4 const bool wasSuspended = m_lastAppState == Qt::ApplicationSuspended;
495 4 const bool nowActive = current == Qt::ApplicationActive;
496
3/4
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 27 taken 3 times.
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 27 not taken.
4 if (wasSuspended && nowActive) {
497
5/5
✓ Branch 6 → 7 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 25 → 8 taken 1 time.
✓ Branch 25 → 26 taken 1 time.
3 qCInfo(lcHealth) << "Application resumed (state"
498
3/3
✓ Branch 13 → 14 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 16 → 17 taken 1 time.
3 << m_lastAppState << "→" << current
499
1/1
✓ Branch 17 → 18 taken 1 time.
1 << ") — forcing reconnect";
500 1 m_lifecycle->requestRecovery(
501 NetworkLifecycleCoordinator::Trigger::QtResume);
502 }
503 4 m_lastAppState = current;
504 }
505
506 29 void ConnectionHealthMonitor::handleRecoveryGeneration(quint64 generation,
507 int trigger) {
508
3/4
✓ Branch 2 → 3 taken 29 times.
✗ Branch 2 → 4 not taken.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 28 times.
29 if (generation == 0 || generation <= m_lastRecoveryGeneration)
509 1 return;
510 28 m_lastRecoveryGeneration = generation;
511 28 m_lastProbeMsecsSinceEpoch = 0;
512
2/2
✓ Branch 5 → 6 taken 3 times.
✓ Branch 5 → 7 taken 25 times.
28 if (!m_active)
513 3 return;
514
515 // A long suspend can let the remote peer or an intermediate NAT expire the
516 // socket. When Qt dispatches the queued events after resume, that stale
517 // socket error may reach us just before logind's PrepareForSleep(false).
518 // In that ordering the ordinary error path has already armed (or even
519 // started) the one reconnect this transport needs. Aborting it here would
520 // turn one lifecycle generation into two connection attempts. Adopt that
521 // in-flight recovery into the new generation; the next authenticated state
522 // will mark this generation stable in the normal way.
523
2/2
✓ Branch 7 → 8 taken 23 times.
✓ Branch 7 → 9 taken 2 times.
25 const ImapService::State state = m_imap ? m_imap->state()
524 25 : ImapService::State::Disconnected;
525 25 const bool connectionAttemptInProgress =
526
1/2
✓ Branch 11 → 12 taken 24 times.
✗ Branch 11 → 16 not taken.
24 state == ImapService::State::Connecting ||
527
1/2
✓ Branch 12 → 13 taken 24 times.
✗ Branch 12 → 16 not taken.
24 state == ImapService::State::Connected ||
528
1/2
✓ Branch 13 → 14 taken 24 times.
✗ Branch 13 → 16 not taken.
24 state == ImapService::State::Greeting ||
529
1/2
✓ Branch 14 → 15 taken 24 times.
✗ Branch 14 → 16 not taken.
24 state == ImapService::State::Capability ||
530
3/4
✓ Branch 10 → 11 taken 24 times.
✓ Branch 10 → 16 taken 1 time.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 24 times.
49 state == ImapService::State::StartingTLS ||
531 state == ImapService::State::Authenticating;
532
4/4
✓ Branch 18 → 19 taken 4 times.
✓ Branch 18 → 23 taken 21 times.
✓ Branch 24 → 25 taken 3 times.
✓ Branch 24 → 40 taken 22 times.
29 if (m_hadOutage &&
533
4/4
✓ Branch 20 → 21 taken 2 times.
✓ Branch 20 → 22 taken 2 times.
✓ Branch 21 → 22 taken 1 time.
✓ Branch 21 → 23 taken 1 time.
4 (m_reconnectTimer.isActive() || connectionAttemptInProgress)) {
534
5/5
✓ Branch 25 → 26 taken 3 times.
✓ Branch 29 → 30 taken 3 times.
✓ Branch 30 → 31 taken 3 times.
✓ Branch 38 → 27 taken 3 times.
✓ Branch 38 → 39 taken 3 times.
6 qCInfo(lcHealth) << "Adopting already pending reconnect into lifecycle"
535
4/4
✓ Branch 31 → 32 taken 3 times.
✓ Branch 32 → 33 taken 3 times.
✓ Branch 33 → 34 taken 3 times.
✓ Branch 34 → 35 taken 3 times.
3 << "generation" << generation << "trigger" << trigger;
536 3 return;
537 }
538 // S92.5.6: the primary found the path dead; a connection that still
539 // works must not be torn down, so it verifies itself instead.
540
2/2
✓ Branch 40 → 41 taken 4 times.
✓ Branch 40 → 50 taken 18 times.
22 if (trigger == static_cast<int>(NetworkLifecycleCoordinator::Trigger::SocketFailure)) {
541
1/2
✓ Branch 41 → 42 taken 4 times.
✗ Branch 41 → 49 not taken.
4 if (m_imap)
542
1/1
✓ Branch 45 → 46 taken 4 times.
8 m_imap->verifyAfterPathFailure(QStringLiteral("primary probe timeout"));
543 4 return;
544 }
545
546
1/1
✓ Branch 57 → 58 taken 18 times.
54 forceReconnect(QStringLiteral("lifecycle generation %1 trigger %2")
547
1/1
✓ Branch 54 → 55 taken 18 times.
36 .arg(generation)
548
1/1
✓ Branch 56 → 57 taken 18 times.
54 .arg(trigger));
549 }
550
551 ✗ bool ConnectionHealthMonitor::isTransportMediumMetered() const {
552 ✗ return transportMediumIsMetered(m_lastTransportMedium);
553 }
554
555 3 bool ConnectionHealthMonitor::transportMediumIsMetered(
556 QNetworkInformation::TransportMedium medium) {
557 // Unknown (including no backend) reports metered so speculation defaults
558 // to the conservative mode without platform signals (T-87.G2).
559
1/2
✓ Branch 3 → 4 taken 3 times.
✗ Branch 3 → 5 not taken.
3 return medium == QNetworkInformation::TransportMedium::Cellular ||
560
2/4
✓ Branch 2 → 3 taken 3 times.
✗ Branch 2 → 5 not taken.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 3 times.
6 medium == QNetworkInformation::TransportMedium::Bluetooth ||
561 3 medium == QNetworkInformation::TransportMedium::Unknown;
562 }
563
564 // S92.5.3: a known cellular/bluetooth link keeps the conservative 15 s
565 // cadence (radio wake-ups); every other medium, including an unknown one,
566 // uses the primary cadence. The probe itself is a few dozen bytes.
567 887 void ConnectionHealthMonitor::applyProbeCadence() {
568
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 887 times.
887 if (!m_systemWatchEnabled)
569 ✗ return;
570 887 const bool knownMetered =
571
2/2
✓ Branch 4 → 5 taken 886 times.
✓ Branch 4 → 6 taken 1 time.
1773 m_lastTransportMedium == QNetworkInformation::TransportMedium::Cellular ||
572
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 886 times.
886 m_lastTransportMedium == QNetworkInformation::TransportMedium::Bluetooth;
573
2/2
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 10 taken 886 times.
887 const int interval = knownMetered ? PROBE_METERED_MS : PROBE_PRIMARY_MS;
574
2/2
✓ Branch 12 → 13 taken 2 times.
✓ Branch 12 → 31 taken 885 times.
887 if (m_probeTimer.interval() != interval) {
575
7/7
✓ Branch 13 → 14 taken 2 times.
✓ Branch 17 → 18 taken 2 times.
✓ Branch 18 → 19 taken 2 times.
✓ Branch 19 → 20 taken 2 times.
✓ Branch 20 → 21 taken 2 times.
✓ Branch 29 → 15 taken 2 times.
✓ Branch 29 → 30 taken 2 times.
6 qCInfo(lcHealth) << "Probe cadence" << interval << "ms (medium"
576
2/2
✓ Branch 22 → 23 taken 2 times.
✓ Branch 23 → 24 taken 2 times.
4 << m_lastTransportMedium << ")";
577 2 m_probeTimer.setInterval(interval);
578 }
579 }
580