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: 82.2% 865 / 0 / 1052
Functions: 89.9% 62 / 0 / 69
Branches: 66.3% 888 / 0 / 1340

controller/MailMutationPipeline.cpp
Line Branch Exec Source
1 #include "controller/MailMutationPipeline.h"
2
3 #include <QDeadlineTimer>
4 #include <QFutureWatcher>
5 #include <QLoggingCategory>
6 #include <QPointer>
7 #include <QtConcurrentRun>
8 #include <algorithm>
9 #include <limits>
10 #include <utility>
11 #include "data/MailCache.h"
12 #include "data/Models.h"
13 #include "performance/MutationPerformanceTracker.h"
14 #include "service/ImapService.h"
15 #include "service/NetworkLifecycleCoordinator.h"
16 #include "ui/MailListModel.h"
17 #include "ui/MailThreadModel.h"
18 #include "util/MessageId.h"
19
20 // Same category as MailController (defined there), see docs/LOGGING.md.
21 Q_DECLARE_LOGGING_CATEGORY(lcController)
22
23 1050 MailMutationPipeline::MailMutationPipeline(
24 ImapService *imap, ImapService *const &bodyImap, MailCache *cache, MailListModel *model,
25 MailThreadModel *const &threadModel, const FolderContext &folder,
26 const ImapConfig &imapConfig, const QSet<QString> &unavailableFolders, Deps deps,
27 1050 QObject *parent)
28 1050 : QObject(parent), m_imap(imap), m_bodyImap(bodyImap), m_cache(cache), m_model(model),
29 1050 m_threadModel(threadModel), m_folder(folder), m_imapConfig(imapConfig),
30
2/2
✓ Branch 13 → 14 taken 1050 times.
✓ Branch 14 → 15 taken 1050 times.
1050 m_unavailableFolders(unavailableFolders), m_deps(std::move(deps)) {
31
1/1
✓ Branch 17 → 18 taken 1050 times.
1050 m_appendPayloadPool.setMaxThreadCount(1);
32
1/1
✓ Branch 18 → 19 taken 1050 times.
1050 m_appendWritePool.setMaxThreadCount(1);
33 1050 }
34
35 // Mutation consumers of the main connection, connected by the controller
36 // constructor at their former position (after the move-result slots).
37 1050 void MailMutationPipeline::connectMainConnection(ImapService *imap) {
38 1050 connect(imap, &ImapService::flagSnapshotsReceived, this,
39
1/1
✓ Branch 2 → 3 taken 1050 times.
1050 &MailMutationPipeline::onFlagSnapshotsReceived);
40 1050 connect(imap, &ImapService::verifyFlagsReceived, this,
41
1/1
✓ Branch 4 → 5 taken 1050 times.
1050 &MailMutationPipeline::onVerificationFlagsReceived);
42 1050 connect(imap, &ImapService::mutationDispatched, this,
43
1/1
✓ Branch 6 → 7 taken 1050 times.
1050 &MailMutationPipeline::onMutationDispatched);
44 1050 connect(imap, &ImapService::mutationCompleted, this,
45
1/1
✓ Branch 8 → 9 taken 1050 times.
1050 [this](qint64 token, ImapService::MutationOutcome outcome,
46 const QString &error, const QString &uidPlus) {
47 12 onMutationCompleted(token, static_cast<int>(outcome), error,
48 uidPlus);
49 12 });
50 1050 connect(imap, &ImapService::stateChanged, this,
51
1/1
✓ Branch 10 → 11 taken 1050 times.
1050 [this](ImapService::State state) {
52
4/4
✓ Branch 2 → 3 taken 62 times.
✓ Branch 2 → 4 taken 2 times.
✓ Branch 3 → 4 taken 5 times.
✓ Branch 3 → 19 taken 57 times.
64 if (state == ImapService::State::Disconnected ||
53 state == ImapService::State::Error) {
54 7 cancelPendingAppendPayloadReads(m_imap);
55 7 m_mutationsScheduledForDispatch.clear();
56 7 for (auto it = m_appendPayloadReads.cbegin();
57
1/2
✗ Branch 12 → 7 not taken.
✓ Branch 12 → 13 taken 7 times.
7 it != m_appendPayloadReads.cend(); ++it)
58 ✗ m_mutationsScheduledForDispatch.insert(it.key());
59 7 m_mutationsScheduledForVerification.clear();
60 7 m_deferredMutationVerifications.clear();
61 7 m_mutationVerificationAttempts.clear();
62 7 m_resentMutations.clear();
63 7 m_slowVerificationReports.clear();
64 7 ++m_mutationVerificationGeneration;
65 }
66
2/2
✓ Branch 19 → 20 taken 9 times.
✓ Branch 19 → 22 taken 55 times.
64 if (state == ImapService::State::Authenticated)
67 9 QTimer::singleShot(0, this,
68
1/1
✓ Branch 20 → 21 taken 9 times.
9 &MailMutationPipeline::replayMutationJournal);
69 64 });
70 1050 }
71
72 // Journal removals retire the token's verification state and pending action.
73 1050 void MailMutationPipeline::connectCacheSignals() {
74
1/1
✓ Branch 2 → 3 taken 1050 times.
1050 connect(m_cache, &MailCache::mutationRemoved, this, [this](qint64 token) {
75 64 m_slowVerificationReports.remove(token);
76 64 m_mutationsScheduledForVerification.remove(token);
77 64 m_deferredMutationVerifications.remove(token);
78 64 m_mutationVerificationAttempts.remove(token);
79 64 m_resentMutations.remove(token);
80
1/1
✓ Branch 8 → 9 taken 64 times.
64 m_deps.pendingActionChanged(token, {});
81 64 });
82 1050 }
83
84 1043 void MailMutationPipeline::resetForAccountSwitch() {
85 1043 ++m_mutationVerificationGeneration;
86 1043 m_mutationsScheduledForVerification.clear();
87 1043 m_deferredMutationVerifications.clear();
88 1043 m_mutationVerificationAttempts.clear();
89 1043 m_resentMutations.clear();
90 1043 m_slowVerificationReports.clear();
91 1043 m_ambiguousReconciliationQueue.clear();
92
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 1043 times.
1043 if (m_activeReconciliationToken != 0) finishAmbiguousReconciliation();
93 1043 }
94
95 1050 void MailMutationPipeline::shutdown() {
96 1050 cancelPendingAppendPayloadReads();
97
1/1
✓ Branch 4 → 5 taken 1050 times.
1050 m_appendPayloadPool.waitForDone();
98
1/1
✓ Branch 6 → 7 taken 1050 times.
1050 m_appendWritePool.waitForDone();
99 1050 }
100
101 // S91.0.6: mutation dispatch/verification consumers of the body connection.
102 19 void MailMutationPipeline::connectMutationVerification(ImapService *bodyImap) {
103 19 connect(bodyImap, &ImapService::flagSnapshotsReceived, this,
104
1/1
✓ Branch 2 → 3 taken 19 times.
19 &MailMutationPipeline::onFlagSnapshotsReceived);
105 19 connect(bodyImap, &ImapService::verifyFlagsReceived, this,
106
1/1
✓ Branch 4 → 5 taken 19 times.
19 &MailMutationPipeline::onVerificationFlagsReceived);
107 19 connect(bodyImap, &ImapService::mutationDispatched, this,
108
1/1
✓ Branch 6 → 7 taken 19 times.
19 &MailMutationPipeline::onMutationDispatched);
109 19 connect(bodyImap, &ImapService::mutationCompleted, this,
110
1/1
✓ Branch 8 → 9 taken 19 times.
19 [this](qint64 token, ImapService::MutationOutcome outcome,
111 const QString &error, const QString &uidPlus) {
112 ✗ onMutationCompleted(token, static_cast<int>(outcome), error,
113 uidPlus);
114 ✗ });
115 19 connect(bodyImap, &ImapService::stateChanged, this,
116
1/1
✓ Branch 10 → 11 taken 19 times.
19 [this, bodyImap](ImapService::State s) {
117
3/4
✓ Branch 2 → 3 taken 3 times.
✓ Branch 2 → 4 taken 1 time.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 20 taken 3 times.
4 if (s == ImapService::State::Disconnected ||
118 s == ImapService::State::Error) {
119 1 cancelPendingAppendPayloadReads(bodyImap);
120 1 m_mutationsScheduledForDispatch.clear();
121 1 for (auto it = m_appendPayloadReads.cbegin();
122
1/2
✗ Branch 12 → 7 not taken.
✓ Branch 12 → 13 taken 1 time.
1 it != m_appendPayloadReads.cend(); ++it)
123 ✗ m_mutationsScheduledForDispatch.insert(it.key());
124 1 m_mutationsScheduledForVerification.clear();
125 1 m_deferredMutationVerifications.clear();
126 1 m_mutationVerificationAttempts.clear();
127 1 m_resentMutations.clear();
128 1 m_slowVerificationReports.clear();
129 1 ++m_mutationVerificationGeneration;
130
1/2
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 1 time.
1 if (m_activeReconciliationToken != 0)
131 ✗ finishAmbiguousReconciliation();
132 }
133
2/2
✓ Branch 20 → 21 taken 2 times.
✓ Branch 20 → 22 taken 2 times.
4 if (s == ImapService::State::Authenticated)
134 2 replayMutationJournal();
135 4 });
136 19 }
137
138 90 qint64 MailMutationPipeline::persistDesiredMutation(qint64 folderId, qint64 uid,
139 MutationKind kind,
140 const QString &flag,
141 bool desiredState) {
142
1/1
✓ Branch 2 → 3 taken 90 times.
90 const QString folderPath = m_cache->folderPath(folderId);
143
1/1
✓ Branch 3 → 4 taken 90 times.
90 const auto header = m_cache->header(folderId, uid);
144
8/8
✓ Branch 5 → 6 taken 89 times.
✓ Branch 5 → 10 taken 1 time.
✓ Branch 7 → 8 taken 88 times.
✓ Branch 7 → 10 taken 1 time.
✓ Branch 9 → 10 taken 1 time.
✓ Branch 9 → 11 taken 87 times.
✓ Branch 12 → 13 taken 3 times.
✓ Branch 12 → 14 taken 87 times.
90 if (m_folder.accountId.isEmpty() || folderPath.isEmpty() || !header)
145 3 return 0;
146
147 87 MutationRecord record;
148 87 record.mailId = MailId{m_folder.accountId, folderId, uid};
149 87 record.folderPath = folderPath;
150 87 record.kind = kind;
151 87 record.replayClass = MutationReplayClass::DesiredState;
152 87 record.phase = MutationPhase::Queued;
153 87 record.flag = flag;
154 87 record.desiredState = desiredState;
155 87 record.messageId = header->messageId;
156
1/1
✓ Branch 21 → 22 taken 87 times.
87 record.uidValidity = m_cache->uidValidity(folderId);
157 // S106-184 (INV-13/15): a record without the mailbox epoch would run
158 // unfenced against whatever mailbox the UID names after a reset. A
159 // folder never opened on the server has no confirmed epoch yet; labels
160 // and the selection writers refuse it the same way.
161
2/2
✓ Branch 22 → 23 taken 2 times.
✓ Branch 22 → 39 taken 85 times.
87 if (record.uidValidity == 0) {
162
6/6
✓ Branch 23 → 24 taken 2 times.
✓ Branch 27 → 28 taken 2 times.
✓ Branch 28 → 29 taken 2 times.
✓ Branch 29 → 30 taken 2 times.
✓ Branch 34 → 25 taken 2 times.
✓ Branch 34 → 35 taken 2 times.
4 qCWarning(lcController) << "Desired state not queued: folder" << folderId
163
1/1
✓ Branch 30 → 31 taken 2 times.
2 << "has no confirmed UIDVALIDITY";
164
2/2
✓ Branch 35 → 36 taken 2 times.
✓ Branch 36 → 37 taken 2 times.
2 m_deps.statusMessage(tr("Action not queued: open the folder once so its server identity is known."));
165 2 return 0;
166 }
167 auto &performanceTracker =
168
1/1
✓ Branch 39 → 40 taken 85 times.
85 MailJD::Performance::MutationPerformanceTracker::instance();
169
1/1
✓ Branch 40 → 41 taken 85 times.
85 performanceTracker.journalStarted();
170
1/1
✓ Branch 42 → 43 taken 85 times.
85 const auto token = m_cache->journal().enqueueMutation(record);
171
1/2
✗ Branch 44 → 45 not taken.
✓ Branch 44 → 68 taken 85 times.
85 if (!token) {
172 ✗ performanceTracker.journalFinished(false);
173 ✗ qCWarning(lcController) << "Mutation journal rejected desired state:"
174 ✗ << m_cache->lastError();
175 ✗ m_deps.statusMessage(tr("Action not queued: %1").arg(m_cache->lastError()));
176 ✗ return 0;
177 }
178
1/1
✓ Branch 69 → 70 taken 85 times.
85 performanceTracker.journalFinished(true, *token);
179 85 return *token;
180 90 }
181
182 4 bool MailMutationPipeline::retryAppendMutation(qint64 token) {
183
1/1
✓ Branch 3 → 4 taken 4 times.
4 const auto record = m_cache->journal().mutation(token);
184
3/4
✓ Branch 7 → 8 taken 4 times.
✗ Branch 7 → 16 not taken.
✓ Branch 10 → 11 taken 2 times.
✓ Branch 10 → 16 taken 2 times.
8 if (!record || record->kind != MutationKind::Append || record->mailId.accountId != m_folder.accountId ||
185
5/6
✓ Branch 5 → 6 taken 4 times.
✗ Branch 5 → 16 not taken.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 16 taken 1 time.
✓ Branch 18 → 19 taken 3 times.
✓ Branch 18 → 20 taken 1 time.
9 record->phase != MutationPhase::Failed ||
186
2/3
✓ Branch 14 → 15 taken 1 time.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 1 time.
4 !m_cache->journal().requeueFailedAppend(token, m_folder.accountId)) return false;
187
1/1
✓ Branch 20 → 21 taken 1 time.
1 QTimer::singleShot(0, this, &MailMutationPipeline::replayMutationJournal);
188 1 return true;
189 4 }
190
191 13 qint64 MailMutationPipeline::queueAppend(const QString &folderPath,
192 const QByteArray &rfcMessage,
193 const QString &flags,
194 const QString &messageId,
195 const QByteArray &precomputedSha256) {
196
1/1
✓ Branch 2 → 3 taken 13 times.
13 const QString canonicalMessageId = MessageId::canonicalFirst(messageId);
197
10/10
✓ Branch 4 → 5 taken 12 times.
✓ Branch 4 → 11 taken 1 time.
✓ Branch 6 → 7 taken 11 times.
✓ Branch 6 → 11 taken 1 time.
✓ Branch 8 → 9 taken 10 times.
✓ Branch 8 → 11 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 12 taken 9 times.
✓ Branch 13 → 14 taken 4 times.
✓ Branch 13 → 18 taken 9 times.
23 if (m_folder.accountId.isEmpty() || folderPath.isEmpty() || rfcMessage.isEmpty() ||
198 10 canonicalMessageId.isEmpty()) {
199
2/2
✓ Branch 14 → 15 taken 4 times.
✓ Branch 15 → 16 taken 4 times.
4 m_deps.statusMessage(tr("Message copy not queued: invalid append metadata"));
200 4 return 0;
201 }
202
203 9 MutationRecord record;
204 // APPEND creates its server MailId. Folder/UID zero deliberately express
205 // that no existing server object may be targeted or inferred.
206 9 record.mailId = MailId{m_folder.accountId, 0, 0};
207 9 record.folderPath = folderPath;
208 9 record.kind = MutationKind::Append;
209 9 record.replayClass = MutationReplayClass::AmbiguousOperation;
210 9 record.phase = MutationPhase::Queued;
211 9 record.targetFolder = folderPath;
212 9 record.messageId = canonicalMessageId;
213
1/1
✓ Branch 25 → 26 taken 9 times.
9 const auto token = m_cache->journal().enqueueAppendMutation(record, rfcMessage, flags,
214 precomputedSha256);
215
2/2
✓ Branch 27 → 28 taken 1 time.
✓ Branch 27 → 50 taken 8 times.
9 if (!token) {
216
5/5
✓ Branch 28 → 29 taken 1 time.
✓ Branch 32 → 33 taken 1 time.
✓ Branch 33 → 34 taken 1 time.
✓ Branch 40 → 30 taken 1 time.
✓ Branch 40 → 41 taken 1 time.
2 qCWarning(lcController) << "Mutation journal rejected APPEND:"
217
2/2
✓ Branch 34 → 35 taken 1 time.
✓ Branch 35 → 36 taken 1 time.
1 << m_cache->lastError();
218
1/1
✓ Branch 45 → 46 taken 1 time.
1 m_deps.statusMessage(
219
3/3
✓ Branch 41 → 42 taken 1 time.
✓ Branch 43 → 44 taken 1 time.
✓ Branch 44 → 45 taken 1 time.
3 tr("Message copy not queued: %1").arg(m_cache->lastError()));
220 1 return 0;
221 }
222
223
1/1
✓ Branch 51 → 52 taken 8 times.
8 QTimer::singleShot(0, this, [this, token = *token]() {
224
2/3
✓ Branch 3 → 4 taken 3 times.
✓ Branch 5 → 6 taken 3 times.
✗ Branch 5 → 8 not taken.
3 if (const auto record = m_cache->journal().mutation(token))
225
1/1
✓ Branch 7 → 8 taken 3 times.
3 dispatchMutation(*record);
226 3 });
227 8 return *token;
228 13 }
229
230 ✗ void MailMutationPipeline::queueAppendAsync(const QString &folderPath,
231 const QByteArray &rfcMessage,
232 const QString &flags,
233 const QString &messageId,
234 const QByteArray &precomputedSha256,
235 std::function<void(qint64)> callback) {
236 ✗ const QString canonicalMessageId = MessageId::canonicalFirst(messageId);
237 ✗ if (m_folder.accountId.isEmpty() || folderPath.isEmpty() ||
238 ✗ canonicalMessageId.isEmpty() || rfcMessage.isEmpty() ||
239 ✗ rfcMessage.size() > MailCache::MaximumAppendMessageBytes ||
240 ✗ precomputedSha256.size() != 32 ||
241 ✗ !m_cache->isOpen() || m_cache->databasePath().isEmpty() ||
242 ✗ m_pendingAppendWrites >= 2 ||
243 ✗ m_pendingAppendWriteBytes > 2 * MailCache::MaximumAppendMessageBytes -
244 ✗ rfcMessage.size()) {
245 ✗ m_deps.statusMessage(tr("Message copy not queued: invalid metadata or busy writer"));
246 ✗ if (callback) callback(0);
247 ✗ return;
248 }
249 ✗ MutationRecord record;
250 ✗ record.mailId = MailId{m_folder.accountId, 0, 0};
251 ✗ record.folderPath = folderPath;
252 ✗ record.kind = MutationKind::Append;
253 ✗ record.replayClass = MutationReplayClass::AmbiguousOperation;
254 ✗ record.phase = MutationPhase::Queued;
255 ✗ record.targetFolder = folderPath;
256 ✗ record.messageId = canonicalMessageId;
257 ✗ const QString databasePath = m_cache->databasePath();
258 ✗ const qint64 retainedBytes = rfcMessage.size();
259 ✗ ++m_pendingAppendWrites;
260 ✗ m_pendingAppendWriteBytes += retainedBytes;
261 struct AppendWriteResult {
262 qint64 token = 0;
263 QString error;
264 };
265 ✗ auto *watcher = new QFutureWatcher<AppendWriteResult>(this);
266 ✗ connect(watcher, &QFutureWatcher<AppendWriteResult>::finished, this,
267 ✗ [this, watcher, record, databasePath, retainedBytes,
268 ✗ callback = std::move(callback)]() mutable {
269 ✗ const auto result = watcher->result();
270 ✗ watcher->deleteLater();
271 ✗ --m_pendingAppendWrites;
272 ✗ m_pendingAppendWriteBytes -= retainedBytes;
273 ✗ if (result.token > 0) {
274 // A profile/account handover cannot dispatch the captured identity.
275 // Its durable journal entry remains available when that account returns.
276 ✗ if (m_cache->databasePath() == databasePath &&
277 ✗ m_folder.accountId == record.mailId.accountId)
278 ✗ QTimer::singleShot(0, this, &MailMutationPipeline::replayMutationJournal);
279 } else {
280 ✗ qCWarning(lcController) << "Mutation journal rejected APPEND:"
281 ✗ << result.error;
282 ✗ m_deps.statusMessage(tr("Message copy not queued: %1").arg(result.error));
283 }
284 ✗ if (callback) callback(result.token);
285 ✗ });
286 ✗ watcher->setFuture(QtConcurrent::run(&m_appendWritePool,
287 ✗ [databasePath, record, rfcMessage, flags,
288 precomputedSha256]() -> AppendWriteResult {
289 ✗ MailCache writer;
290 ✗ if (!writer.openExistingWriter(databasePath, 2000))
291 ✗ return {0, writer.lastError()};
292 ✗ const auto token = writer.journal().enqueueAppendMutation(record, rfcMessage, flags,
293 ✗ precomputedSha256);
294 ✗ return {token.value_or(0), token ? QString{} : writer.lastError()};
295 ✗ }));
296 ✗ }
297
298 // dispatchMutation step: account, suspend, mailbox availability and
299 // UIDVALIDITY guards. False: the record is not dispatched now (a stale or
300 // unavailable mailbox quarantines it).
301 3258 bool MailMutationPipeline::admitMutationForDispatch(const MutationRecord &record) {
302 3258 if (record.mailId.accountId != m_folder.accountId ||
303
2/2
✓ Branch 4 → 5 taken 3256 times.
✓ Branch 4 → 11 taken 1 time.
3257 record.phase == MutationPhase::Quarantined ||
304
2/2
✓ Branch 5 → 6 taken 3255 times.
✓ Branch 5 → 11 taken 1 time.
3256 record.phase == MutationPhase::Failed ||
305
4/4
✓ Branch 6 → 7 taken 3254 times.
✓ Branch 6 → 11 taken 1 time.
✓ Branch 8 → 9 taken 3252 times.
✓ Branch 8 → 11 taken 2 times.
6509 record.phase == MutationPhase::Confirmed ||
306
4/4
✓ Branch 3 → 4 taken 3257 times.
✓ Branch 3 → 11 taken 1 time.
✓ Branch 13 → 14 taken 18 times.
✓ Branch 13 → 15 taken 3240 times.
9769 m_mutationsScheduledForDispatch.contains(record.token) ||
307
2/2
✓ Branch 10 → 11 taken 12 times.
✓ Branch 10 → 12 taken 3240 times.
3252 !m_mutationsScheduledForDispatch.isEmpty())
308 18 return false;
309
1/2
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 22 taken 3240 times.
3240 if (NetworkLifecycleCoordinator::instance()->isSuspending()) {
310 ✗ m_deps.statusMessage(tr("Action queued until the computer resumes"));
311 ✗ return false;
312 }
313
314
3/6
✓ Branch 23 → 24 taken 3240 times.
✗ Branch 23 → 26 not taken.
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 27 taken 3240 times.
✗ Branch 28 → 29 not taken.
✓ Branch 28 → 39 taken 3240 times.
6480 if (m_unavailableFolders.contains(record.folderPath) ||
315 3240 m_unavailableFolders.contains(record.targetFolder)) {
316 ✗ m_cache->journal().updateMutationPhase(record.token, MutationPhase::Quarantined,
317 ✗ QStringLiteral("Mailbox unavailable after LIST; action requires review"));
318 ✗ return false;
319 }
320
321 3240 const quint32 currentValidity = m_cache->uidValidity(record.mailId.folderId);
322
4/4
✓ Branch 40 → 41 taken 3227 times.
✓ Branch 40 → 56 taken 13 times.
✓ Branch 41 → 42 taken 3226 times.
✓ Branch 41 → 56 taken 1 time.
3240 if (record.uidValidity != 0 && currentValidity != 0 &&
323
2/2
✓ Branch 42 → 43 taken 2 times.
✓ Branch 42 → 56 taken 3224 times.
3226 currentValidity != record.uidValidity) {
324 2 m_cache->journal().updateMutationPhase(
325
1/1
✓ Branch 48 → 49 taken 2 times.
2 record.token, MutationPhase::Quarantined,
326 4 QStringLiteral("UIDVALIDITY changed; stale UID was not replayed"));
327
2/2
✓ Branch 52 → 53 taken 2 times.
✓ Branch 53 → 54 taken 2 times.
2 m_deps.statusMessage(tr("Queued action needs review after mailbox reset"));
328 2 return false;
329 }
330 3238 return true;
331 }
332
333 // Sprint 102 S102.5.1: the connection that sends a UID command has the
334 // record's mailbox selected — and, when both are known, under the
335 // UIDVALIDITY the record was queued with. Checked on the live selection, not
336 // only through the cache, whose update order relative to this dispatch is
337 // not guaranteed. Zero stays the unknown-value compatibility arm.
338 26 bool MailMutationPipeline::selectedIdentityMatches(const ImapService *service,
339 const MutationRecord &record) {
340 26 const quint32 selected = service->selectedUidValidity();
341
2/2
✓ Branch 5 → 6 taken 24 times.
✓ Branch 5 → 10 taken 2 times.
76 return service->selectedFolder() == record.folderPath &&
342
7/8
✓ Branch 6 → 7 taken 19 times.
✓ Branch 6 → 9 taken 5 times.
✓ Branch 7 → 8 taken 4 times.
✓ Branch 7 → 9 taken 15 times.
✓ Branch 8 → 9 taken 3 times.
✓ Branch 8 → 10 taken 1 time.
✓ Branch 11 → 12 taken 26 times.
✗ Branch 11 → 13 not taken.
50 (record.uidValidity == 0 || selected == 0 || selected == record.uidValidity);
343 }
344
345 // dispatchMutation step (F4.3): reserve, claim and send a batch of
346 // same-source/target/epoch moves as one UID MOVE uid-set command.
347 2827 void MailMutationPipeline::dispatchMoveBatch(const QList<MutationRecord> &batch) {
348 5 auto dispatchBatchWith = [this, batch](ImapService *service) {
349 // SELECT on the secondary connection can invalidate captured journal
350 // records. Re-read and claim each record before any UID command goes
351 // to the wire; a record that no longer matches keeps its journal
352 // entry and releases its reservation for the next replay pass.
353 5 QList<qint64> uids, tokens;
354
2/2
✓ Branch 38 → 4 taken 16 times.
✓ Branch 38 → 39 taken 5 times.
21 for (const auto &entry : batch) {
355
1/1
✓ Branch 6 → 7 taken 16 times.
16 const auto current = m_cache->journal().mutation(entry.token);
356
1/2
✓ Branch 11 → 12 taken 16 times.
✗ Branch 11 → 19 not taken.
32 if (!current || current->mailId.accountId != m_folder.accountId ||
357
3/6
✓ Branch 8 → 9 taken 16 times.
✗ Branch 8 → 19 not taken.
✓ Branch 14 → 15 taken 16 times.
✗ Branch 14 → 19 not taken.
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 24 taken 16 times.
48 !selectedIdentityMatches(service, *current) ||
358
2/3
✓ Branch 17 → 18 taken 16 times.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 16 times.
16 !m_cache->journal().claimMutationForDispatch(*current)) {
359 ✗ releaseDispatchReservation(entry.token);
360 ✗ continue;
361 }
362
1/1
✓ Branch 25 → 26 taken 16 times.
16 tokens.append(current->token);
363
1/1
✓ Branch 27 → 28 taken 16 times.
16 uids.append(current->mailId.uid);
364 // S106-189: the batch holds the single-flight slot like a single
365 // record does — also when it went out on the already-selected
366 // connection without reserveAndSelectForDispatch.
367
1/1
✓ Branch 29 → 30 taken 16 times.
16 m_mutationsScheduledForDispatch.insert(current->token);
368 16 }
369
1/2
✓ Branch 40 → 41 taken 5 times.
✗ Branch 40 → 43 not taken.
5 if (!tokens.isEmpty())
370
1/1
✓ Branch 42 → 43 taken 5 times.
5 service->moveMessageSet(uids, batch.first().targetFolder, tokens);
371 2832 };
372
2/2
✓ Branch 4 → 5 taken 2826 times.
✓ Branch 4 → 35 taken 1 time.
2827 if (m_imapConfig.host.isEmpty()) {
373 const bool mainOwnsFolder =
374
2/2
✓ Branch 9 → 10 taken 2591 times.
✓ Branch 9 → 12 taken 4 times.
5421 batch.first().folderPath == m_folder.currentFolder &&
375
1/2
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 17 taken 2591 times.
5186 (m_imap->state() == ImapService::State::Selected ||
376
3/4
✓ Branch 7 → 8 taken 2595 times.
✓ Branch 7 → 17 taken 231 times.
✓ Branch 15 → 16 taken 4 times.
✗ Branch 15 → 17 not taken.
10838 m_imap->state() == ImapService::State::Idling) &&
377
2/2
✓ Branch 18 → 19 taken 4 times.
✓ Branch 18 → 20 taken 2822 times.
2830 m_imap->selectedFolder() == batch.first().folderPath;
378
2/2
✓ Branch 20 → 21 taken 4 times.
✓ Branch 20 → 34 taken 2822 times.
2826 if (mainOwnsFolder) {
379
2/2
✓ Branch 27 → 23 taken 14 times.
✓ Branch 27 → 28 taken 4 times.
18 for (const auto &entry : batch)
380
1/1
✓ Branch 24 → 25 taken 14 times.
14 m_mutationsScheduledForDispatch.insert(entry.token);
381
2/4
✓ Branch 29 → 30 taken 4 times.
✓ Branch 30 → 31 taken 4 times.
✗ Branch 86 → 87 not taken.
✗ Branch 86 → 88 not taken.
4 m_imap->executeAfterIdle([dispatchBatchWith, service = m_imap]() {
382 4 dispatchBatchWith(service);
383 4 });
384 }
385 2826 return;
386 }
387
1/1
✓ Branch 35 → 36 taken 1 time.
1 m_deps.ensureBodyConnection();
388
1/2
✗ Branch 36 → 37 not taken.
✓ Branch 36 → 38 taken 1 time.
1 if (!m_bodyImap)
389 ✗ return;
390
2/4
✓ Branch 39 → 40 taken 1 time.
✗ Branch 39 → 45 not taken.
✓ Branch 43 → 44 taken 1 time.
✗ Branch 43 → 45 not taken.
2 if (m_bodyImap->state() == ImapService::State::Selected &&
391
2/4
✓ Branch 46 → 47 taken 1 time.
✗ Branch 46 → 48 not taken.
✓ Branch 48 → 49 taken 1 time.
✗ Branch 48 → 51 not taken.
2 m_bodyImap->selectedFolder() == batch.first().folderPath) {
392
1/1
✓ Branch 49 → 50 taken 1 time.
1 dispatchBatchWith(m_bodyImap);
393 1 return;
394 }
395 ✗ if (m_bodyImap->state() != ImapService::State::Authenticated &&
396 ✗ m_bodyImap->state() != ImapService::State::Selected)
397 ✗ return;
398 ✗ QList<qint64> tokens;
399 ✗ for (const auto &entry : batch) tokens.append(entry.token);
400 ✗ reserveAndSelectForDispatch(m_bodyImap, tokens, batch.first().folderPath,
401 ✗ [dispatchBatchWith, service = m_bodyImap]() {
402 ✗ dispatchBatchWith(service);
403 ✗ });
404 2827 }
405
406 // dispatchMutation step: read and verify an APPEND payload on the payload
407 // lane; the durable claim happens right before the bytes reach the wire.
408 3 void MailMutationPipeline::startAppendPayloadRead(ImapService *service, qint64 token,
409 const MutationRecord &record) {
410 // Read and verify the potentially 80-MiB payload off the GUI thread.
411 // The token reservation blocks other mutations, while its durable
412 // phase remains Queued until the bytes are ready for the wire.
413 3 const QString databasePath = m_cache->databasePath();
414 3 const QString account = record.mailId.accountId;
415
1/1
✓ Branch 4 → 5 taken 3 times.
3 const QPointer<ImapService> target(service);
416 3 const quint64 readSequence = ++m_appendPayloadReadSequence;
417
1/1
✓ Branch 6 → 7 taken 3 times.
3 m_appendPayloadReads.insert(token, {readSequence, target});
418
3/6
✓ Branch 8 → 9 taken 3 times.
✓ Branch 9 → 10 taken 3 times.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 3 times.
✗ Branch 30 → 31 not taken.
✗ Branch 30 → 32 not taken.
3 auto *watcher = new QFutureWatcher<AppendPayloadReadResult>(this);
419 3 connect(watcher, &QFutureWatcher<AppendPayloadReadResult>::finished,
420
1/5
✓ Branch 14 → 15 taken 3 times.
✗ Branch 35 → 36 not taken.
✗ Branch 35 → 37 not taken.
✗ Branch 38 → 39 not taken.
✗ Branch 38 → 40 not taken.
6 this, [this, watcher, target, account, token, readSequence] {
421
1/1
✓ Branch 2 → 3 taken 3 times.
3 auto result = watcher->result();
422
1/1
✓ Branch 3 → 4 taken 3 times.
3 watcher->deleteLater();
423
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 3 times.
3 if (m_appendPayloadReads.value(token).sequence != readSequence) return;
424
1/1
✓ Branch 8 → 9 taken 3 times.
3 m_appendPayloadReads.remove(token);
425
1/1
✓ Branch 10 → 11 taken 3 times.
3 const auto current = m_cache->journal().mutation(token);
426
1/2
✓ Branch 14 → 15 taken 3 times.
✗ Branch 14 → 20 not taken.
6 if (!current || current->kind != MutationKind::Append ||
427
3/6
✓ Branch 12 → 13 taken 3 times.
✗ Branch 12 → 20 not taken.
✓ Branch 17 → 18 taken 3 times.
✗ Branch 17 → 20 not taken.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 25 taken 3 times.
9 current->mailId.accountId != account ||
428
1/2
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 3 times.
3 current->phase != MutationPhase::Queued) {
429 ✗ releaseDispatchReservation(token);
430 ✗ return;
431 }
432
4/8
✓ Branch 26 → 27 taken 3 times.
✗ Branch 26 → 35 not taken.
✓ Branch 28 → 29 taken 3 times.
✗ Branch 28 → 35 not taken.
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 36 taken 3 times.
✗ Branch 37 → 38 not taken.
✓ Branch 37 → 40 taken 3 times.
6 if (m_folder.accountId != account || !target ||
433
0/2
✗ Branch 34 → 35 not taken.
✗ Branch 34 → 36 not taken.
3 (target->state() != ImapService::State::Authenticated &&
434 ✗ target->state() != ImapService::State::Selected)) {
435 ✗ releaseDispatchReservation(token);
436 ✗ return;
437 }
438
1/2
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 56 taken 3 times.
3 if (!result.error.isEmpty()) {
439 ✗ qCWarning(lcController) << "APPEND payload read deferred:" << result.error;
440 ✗ m_mutationsScheduledForDispatch.remove(token);
441 ✗ QTimer::singleShot(2000, this, &MailMutationPipeline::replayMutationJournal);
442 ✗ return;
443 }
444
2/2
✓ Branch 57 → 58 taken 1 time.
✓ Branch 57 → 70 taken 2 times.
3 if (!result.payload) {
445
1/1
✓ Branch 58 → 59 taken 1 time.
1 m_mutationsScheduledForDispatch.remove(token);
446
1/1
✓ Branch 62 → 63 taken 1 time.
2 m_cache->journal().updateMutationPhase(
447 token, MutationPhase::Ambiguous,
448
1/1
✓ Branch 61 → 62 taken 1 time.
2 tr("Message content is not persisted; verifying the target "
449 "mailbox before any user decision"));
450
1/2
✓ Branch 66 → 67 taken 1 time.
✗ Branch 66 → 68 not taken.
1 if (!m_ambiguousReconciliationQueue.contains(token))
451
1/1
✓ Branch 67 → 68 taken 1 time.
1 m_ambiguousReconciliationQueue.enqueue(token);
452 1 QTimer::singleShot(0, this,
453
1/1
✓ Branch 68 → 69 taken 1 time.
1 &MailMutationPipeline::reconcileNextAmbiguousMutation);
454 1 return;
455 }
456
2/3
✓ Branch 72 → 73 taken 2 times.
✗ Branch 73 → 74 not taken.
✓ Branch 73 → 76 taken 2 times.
2 if (!m_cache->journal().claimMutationForDispatch(*current)) {
457 ✗ releaseDispatchReservation(token);
458 ✗ return;
459 }
460
1/1
✓ Branch 77 → 78 taken 2 times.
2 const quint64 requestId = target->reserveAppendRequestId();
461
1/1
✓ Branch 82 → 83 taken 2 times.
4 target->appendMessage(requestId, current->targetFolder,
462 2 result.payload->message, result.payload->flags, token);
463
4/4
✓ Branch 85 → 86 taken 2 times.
✓ Branch 85 → 87 taken 1 time.
✓ Branch 89 → 90 taken 2 times.
✓ Branch 89 → 92 taken 1 time.
4 });
464
1/1
✓ Branch 20 → 21 taken 3 times.
3 watcher->setFuture(QtConcurrent::run(&m_appendPayloadPool,
465
1/5
✓ Branch 19 → 20 taken 3 times.
✗ Branch 47 → 48 not taken.
✗ Branch 47 → 49 not taken.
✗ Branch 50 → 51 not taken.
✗ Branch 50 → 52 not taken.
6 [databasePath, account, token]() -> AppendPayloadReadResult {
466
1/1
✓ Branch 2 → 3 taken 3 times.
3 MailCache reader;
467
2/3
✓ Branch 3 → 4 taken 3 times.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 7 taken 3 times.
3 if (!reader.openReadOnly(databasePath, 2000))
468 ✗ return {{}, reader.lastError()};
469 3 AppendPayloadReadResult result;
470
1/1
✓ Branch 8 → 9 taken 3 times.
3 result.payload = reader.journal().appendPayload(token, account, &result.error);
471 3 return result;
472
0/3
✗ Branch 5 → 6 not taken.
✗ Branch 16 → 17 not taken.
✗ Branch 16 → 18 not taken.
3 }));
473 6 }
474
475 3258 void MailMutationPipeline::dispatchMutation(const MutationRecord &record) {
476
3/3
✓ Branch 2 → 3 taken 3258 times.
✓ Branch 3 → 4 taken 20 times.
✓ Branch 3 → 5 taken 3238 times.
3258 if (!admitMutationForDispatch(record))
477 3257 return;
478
479 // F4.3: same-source/target/epoch journal moves ride one UID MOVE uid-set
480 // command instead of one round trip per mail, so a multi-selection bulk
481 // move finishes in one server exchange.
482
1/1
✓ Branch 5 → 6 taken 3238 times.
3238 const QList<MutationRecord> batch = batchableMoveRecords(record);
483
2/2
✓ Branch 7 → 8 taken 2827 times.
✓ Branch 7 → 10 taken 411 times.
3238 if (batch.size() > 1) {
484
1/1
✓ Branch 8 → 9 taken 2827 times.
2827 dispatchMoveBatch(batch);
485 2827 return;
486 }
487
488 425 auto dispatchWith = [this, token = record.token](ImapService *service) {
489 // SELECT on the secondary connection can invalidate the captured journal
490 // record. Re-read after reconciliation before any UID command reaches wire.
491
1/1
✓ Branch 3 → 4 taken 14 times.
14 const auto current = m_cache->journal().mutation(token);
492
1/2
✓ Branch 8 → 9 taken 13 times.
✗ Branch 8 → 20 not taken.
27 if (!current || current->mailId.accountId != m_folder.accountId ||
493
2/2
✓ Branch 10 → 11 taken 10 times.
✓ Branch 10 → 14 taken 3 times.
13 (current->kind != MutationKind::Append &&
494
6/6
✓ Branch 5 → 6 taken 13 times.
✓ Branch 5 → 20 taken 1 time.
✓ Branch 13 → 14 taken 7 times.
✓ Branch 13 → 20 taken 3 times.
✓ Branch 22 → 23 taken 4 times.
✓ Branch 22 → 25 taken 10 times.
34 !selectedIdentityMatches(service, *current)) ||
495
2/2
✓ Branch 15 → 16 taken 7 times.
✓ Branch 15 → 21 taken 3 times.
10 (current->kind != MutationKind::Append &&
496
2/3
✓ Branch 18 → 19 taken 7 times.
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 7 times.
7 !m_cache->journal().claimMutationForDispatch(*current))) {
497 // F4.3: a record that cannot be claimed right now must not silently
498 // park its journal entry — hand the reservation back and re-drive.
499
1/1
✓ Branch 23 → 24 taken 4 times.
4 releaseDispatchReservation(token);
500 4 return;
501 }
502 // Non-APPEND operations are claimed above. APPEND waits until its worker
503 // payload read completes, immediately before handing bytes to the wire.
504 10 const auto &record = *current;
505
1/1
✓ Branch 26 → 27 taken 10 times.
10 m_mutationsScheduledForDispatch.insert(record.token);
506
2/2
✓ Branch 27 → 28 taken 3 times.
✓ Branch 27 → 29 taken 7 times.
10 if (record.replayClass == MutationReplayClass::DesiredState) {
507 3 service->storeFlag(record.mailId.uid, record.flag, record.desiredState,
508
1/1
✓ Branch 28 → 48 taken 3 times.
3 record.token);
509
2/2
✓ Branch 29 → 30 taken 6 times.
✓ Branch 29 → 31 taken 1 time.
7 } else if (record.kind == MutationKind::Move ||
510
2/2
✓ Branch 30 → 31 taken 1 time.
✓ Branch 30 → 33 taken 5 times.
6 record.kind == MutationKind::Undo) {
511 2 service->moveMessage(record.mailId.uid, record.targetFolder,
512
1/1
✓ Branch 31 → 32 taken 2 times.
2 record.token);
513
2/2
✓ Branch 33 → 34 taken 1 time.
✓ Branch 33 → 35 taken 4 times.
5 } else if (record.kind == MutationKind::Copy) {
514 1 service->copyMessage(record.mailId.uid, record.targetFolder,
515
1/1
✓ Branch 34 → 48 taken 1 time.
1 record.token);
516
2/2
✓ Branch 35 → 36 taken 3 times.
✓ Branch 35 → 37 taken 1 time.
4 } else if (record.kind == MutationKind::Append) {
517
1/1
✓ Branch 36 → 48 taken 3 times.
3 startAppendPayloadRead(service, token, record);
518 } else {
519
1/1
✓ Branch 37 → 38 taken 1 time.
1 m_mutationsScheduledForDispatch.remove(record.token);
520 2 m_cache->journal().updateMutationPhase(
521
1/1
✓ Branch 43 → 44 taken 1 time.
1 record.token, MutationPhase::Ambiguous,
522 2 QStringLiteral("Operation requires explicit recovery support"));
523 }
524
2/2
✓ Branch 50 → 51 taken 10 times.
✓ Branch 50 → 53 taken 4 times.
14 };
525
526 // A configured account always dispatches durable mutations on the
527 // non-idling secondary connection. The main connection can change folders
528 // for navigation while a UID command is serialized; keeping the entire
529 // journal transaction on one secondary connection preserves MailId folder
530 // identity through the actual wire command and its verification readback.
531
2/2
✓ Branch 11 → 12 taken 410 times.
✓ Branch 11 → 42 taken 1 time.
411 if (m_imapConfig.host.isEmpty()) {
532
6/6
✓ Branch 12 → 13 taken 7 times.
✓ Branch 12 → 18 taken 403 times.
✓ Branch 14 → 15 taken 4 times.
✓ Branch 14 → 17 taken 3 times.
✓ Branch 19 → 20 taken 3 times.
✓ Branch 19 → 22 taken 407 times.
421 if (record.kind == MutationKind::Append &&
533
1/2
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 4 times.
11 (m_imap->state() == ImapService::State::Authenticated ||
534 4 m_imap->state() == ImapService::State::Selected)) {
535
1/1
✓ Branch 20 → 21 taken 3 times.
3 dispatchWith(m_imap);
536 3 return;
537 }
538 const bool mainOwnsFolder =
539
2/2
✓ Branch 25 → 26 taken 339 times.
✓ Branch 25 → 28 taken 10 times.
349 record.folderPath == m_folder.currentFolder &&
540
1/2
✗ Branch 27 → 28 not taken.
✓ Branch 27 → 32 taken 339 times.
688 (m_imap->state() == ImapService::State::Selected ||
541
3/4
✓ Branch 23 → 24 taken 349 times.
✓ Branch 23 → 32 taken 58 times.
✓ Branch 30 → 31 taken 10 times.
✗ Branch 30 → 32 not taken.
1095 m_imap->state() == ImapService::State::Idling) &&
542
2/2
✓ Branch 33 → 34 taken 10 times.
✓ Branch 33 → 35 taken 397 times.
417 m_imap->selectedFolder() == record.folderPath;
543
2/2
✓ Branch 35 → 36 taken 10 times.
✓ Branch 35 → 41 taken 397 times.
407 if (mainOwnsFolder) {
544
1/1
✓ Branch 36 → 37 taken 10 times.
10 m_mutationsScheduledForDispatch.insert(record.token);
545
1/1
✓ Branch 38 → 39 taken 10 times.
10 m_imap->executeAfterIdle(
546
1/1
✓ Branch 37 → 38 taken 10 times.
30 [dispatchWith, service = m_imap]() { dispatchWith(service); });
547 }
548 407 return;
549 }
550
1/1
✓ Branch 42 → 43 taken 1 time.
1 m_deps.ensureBodyConnection();
551
1/2
✗ Branch 43 → 44 not taken.
✓ Branch 43 → 45 taken 1 time.
1 if (!m_bodyImap)
552 ✗ return;
553
2/6
✗ Branch 45 → 46 not taken.
✓ Branch 45 → 51 taken 1 time.
✗ Branch 47 → 48 not taken.
✗ Branch 47 → 50 not taken.
✗ Branch 52 → 53 not taken.
✓ Branch 52 → 55 taken 1 time.
1 if (record.kind == MutationKind::Append &&
554 ✗ (m_bodyImap->state() == ImapService::State::Authenticated ||
555 ✗ m_bodyImap->state() == ImapService::State::Selected)) {
556 ✗ dispatchWith(m_bodyImap);
557 ✗ return;
558 }
559
1/4
✗ Branch 56 → 57 not taken.
✓ Branch 56 → 61 taken 1 time.
✗ Branch 59 → 60 not taken.
✗ Branch 59 → 61 not taken.
1 if (m_bodyImap->state() == ImapService::State::Selected &&
560
2/4
✗ Branch 62 → 63 not taken.
✓ Branch 62 → 64 taken 1 time.
✗ Branch 64 → 65 not taken.
✓ Branch 64 → 67 taken 1 time.
1 m_bodyImap->selectedFolder() == record.folderPath) {
561 ✗ dispatchWith(m_bodyImap);
562 ✗ return;
563 }
564
2/6
✗ Branch 68 → 69 not taken.
✓ Branch 68 → 72 taken 1 time.
✗ Branch 70 → 71 not taken.
✗ Branch 70 → 72 not taken.
✗ Branch 73 → 74 not taken.
✓ Branch 73 → 75 taken 1 time.
1 if (m_bodyImap->state() != ImapService::State::Authenticated &&
565 ✗ m_bodyImap->state() != ImapService::State::Selected)
566 ✗ return;
567
568
2/2
✓ Branch 76 → 77 taken 1 time.
✓ Branch 77 → 78 taken 1 time.
1 reserveAndSelectForDispatch(m_bodyImap, {record.token}, record.folderPath,
569
1/1
✓ Branch 75 → 76 taken 1 time.
1 [dispatchWith, service = m_bodyImap]() {
570 1 dispatchWith(service);
571 1 });
572 3238 }
573
574 3238 QList<MutationRecord> MailMutationPipeline::batchableMoveRecords(
575 const MutationRecord &record) const {
576
2/4
✓ Branch 5 → 6 taken 3238 times.
✓ Branch 5 → 7 taken 3238 times.
✗ Branch 43 → 44 not taken.
✗ Branch 43 → 45 not taken.
6476 QList<MutationRecord> batch{record};
577
2/2
✓ Branch 7 → 8 taken 2887 times.
✓ Branch 7 → 9 taken 351 times.
3238 if (record.kind != MutationKind::Move ||
578
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 2887 times.
2887 record.replayClass != MutationReplayClass::AmbiguousOperation)
579 351 return batch;
580
5/5
✓ Branch 11 → 12 taken 2887 times.
✓ Branch 12 → 13 taken 2887 times.
✓ Branch 13 → 14 taken 2887 times.
✓ Branch 38 → 15 taken 271816 times.
✓ Branch 38 → 39 taken 267 times.
272083 for (const auto &other : m_cache->journal().pendingMutations(record.mailId.accountId)) {
581
2/2
✓ Branch 17 → 18 taken 2620 times.
✓ Branch 17 → 19 taken 269196 times.
271816 if (batch.size() >= MaxBatchedMoveMessages)
582 2620 break;
583
1/2
✓ Branch 21 → 22 taken 268655 times.
✗ Branch 21 → 31 not taken.
268655 if (other.token == record.token ||
584 268655 m_mutationsScheduledForDispatch.contains(other.token) ||
585
2/2
✓ Branch 22 → 23 taken 268648 times.
✓ Branch 22 → 31 taken 7 times.
268655 other.phase != MutationPhase::Queued ||
586
2/2
✓ Branch 23 → 24 taken 268621 times.
✓ Branch 23 → 31 taken 27 times.
268648 other.kind != MutationKind::Move ||
587
1/2
✓ Branch 24 → 25 taken 268621 times.
✗ Branch 24 → 31 not taken.
268621 other.replayClass != MutationReplayClass::AmbiguousOperation ||
588
1/2
✓ Branch 25 → 26 taken 268621 times.
✗ Branch 25 → 31 not taken.
268621 other.mailId.folderId != record.mailId.folderId ||
589
1/2
✓ Branch 27 → 28 taken 268621 times.
✗ Branch 27 → 31 not taken.
268621 other.folderPath != record.folderPath ||
590
6/6
✓ Branch 19 → 20 taken 268655 times.
✓ Branch 19 → 31 taken 541 times.
✓ Branch 29 → 30 taken 268612 times.
✓ Branch 29 → 31 taken 9 times.
✓ Branch 33 → 34 taken 584 times.
✓ Branch 33 → 35 taken 268612 times.
806463 other.targetFolder != record.targetFolder ||
591
1/2
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 32 taken 268612 times.
268612 other.uidValidity != record.uidValidity)
592 584 continue;
593
1/1
✓ Branch 35 → 36 taken 268612 times.
268612 batch.append(other);
594 2887 }
595 2887 return batch;
596 3238 }
597
598 20 void MailMutationPipeline::releaseDispatchReservation(qint64 token) {
599 20 m_mutationsScheduledForDispatch.remove(token);
600 // The journal is single-flight; freeing a reservation must re-drive the
601 // next pending record or the whole pipeline waits for an unrelated event.
602
1/1
✓ Branch 3 → 4 taken 20 times.
20 QTimer::singleShot(0, this, &MailMutationPipeline::replayMutationJournal);
603 20 }
604
605 2101 void MailMutationPipeline::cancelPendingAppendPayloadReads(ImapService *service) {
606 2101 QList<qint64> cancelledTokens;
607 2101 for (auto it = m_appendPayloadReads.cbegin();
608
1/2
✗ Branch 19 → 3 not taken.
✓ Branch 19 → 20 taken 2101 times.
2101 it != m_appendPayloadReads.cend(); ++it) {
609 ✗ if (service && it.value().service != service) continue;
610 ✗ const qint64 token = it.key();
611 // No durable phase change has happened; dropping this reservation lets
612 // replay read the same still-Queued journal entry again.
613 ✗ m_mutationsScheduledForDispatch.remove(token);
614 ✗ cancelledTokens.append(token);
615 }
616
3/4
✓ Branch 20 → 21 taken 2101 times.
✓ Branch 21 → 22 taken 2101 times.
✗ Branch 27 → 23 not taken.
✓ Branch 27 → 28 taken 2101 times.
2101 for (const qint64 token : cancelledTokens)
617 ✗ m_appendPayloadReads.remove(token);
618 2101 }
619
620 1 void MailMutationPipeline::reserveAndSelectForDispatch(
621 ImapService *service, const QList<qint64> &tokens, const QString &folderPath,
622 const std::function<void()> &dispatch) {
623
2/2
✓ Branch 8 → 4 taken 1 time.
✓ Branch 8 → 9 taken 1 time.
2 for (qint64 token : tokens)
624
1/1
✓ Branch 5 → 6 taken 1 time.
1 m_mutationsScheduledForDispatch.insert(token);
625 // The connection stays armed until the requested folder actually arrives:
626 // a foreign folderSelected (body fetch, navigation) must not consume it.
627 // The previous single-shot connection leaked the reservation on exactly
628 // that race, wedging the single-flight journal behind a token that never
629 // reached the wire (F4.3: bulk moves that "never finish").
630
1/1
✓ Branch 9 → 10 taken 1 time.
1 const auto connection = std::make_shared<QMetaObject::Connection>();
631 2 *connection = connect(service, &ImapService::folderSelected, this,
632
2/6
✓ Branch 11 → 12 taken 1 time.
✓ Branch 13 → 14 taken 1 time.
✗ Branch 29 → 30 not taken.
✗ Branch 29 → 31 not taken.
✗ Branch 32 → 33 not taken.
✗ Branch 32 → 34 not taken.
2 [this, service, folderPath, dispatch,
633 connection](const QString &path, int, quint32, quint64) {
634
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1 time.
1 if (path != folderPath)
635 ✗ return;
636 1 disconnect(*connection);
637 1 dispatch();
638 1 });
639 // Watchdog: a SELECT that never confirms (request coalescing, a folder
640 // the server rejects silently, a stalled socket) must hand still-queued
641 // reservations back instead of blocking every later mutation. Claimed
642 // records keep their reservation until the wire completion owns them.
643
1/3
✓ Branch 20 → 21 taken 1 time.
✗ Branch 39 → 40 not taken.
✗ Branch 39 → 41 not taken.
1 QTimer::singleShot(m_selectFenceTimeoutMs, this, [this, tokens, connection] {
644 ✗ disconnect(*connection);
645 ✗ bool released = false;
646 ✗ for (qint64 token : tokens) {
647 ✗ const auto current = m_cache->journal().mutation(token);
648 ✗ if (!current || current->phase == MutationPhase::Queued) {
649 ✗ m_mutationsScheduledForDispatch.remove(token);
650 ✗ released = true;
651 }
652 ✗ }
653 ✗ if (released)
654 ✗ QTimer::singleShot(0, this, &MailMutationPipeline::replayMutationJournal);
655 ✗ });
656
1/1
✓ Branch 22 → 23 taken 1 time.
1 service->selectFolder(folderPath);
657 1 }
658
659 956 void MailMutationPipeline::replayMutationJournal() {
660
7/7
✓ Branch 3 → 4 taken 948 times.
✓ Branch 3 → 6 taken 8 times.
✓ Branch 4 → 5 taken 948 times.
✓ Branch 5 → 6 taken 28 times.
✓ Branch 5 → 7 taken 920 times.
✓ Branch 8 → 9 taken 36 times.
✓ Branch 8 → 10 taken 920 times.
956 if (m_folder.accountId.isEmpty() || !m_cache->isOpen())
661 36 return;
662
1/1
✓ Branch 11 → 12 taken 920 times.
920 const auto records = m_cache->journal().pendingMutations(m_folder.accountId);
663
2/2
✓ Branch 76 → 14 taken 2885 times.
✓ Branch 76 → 77 taken 920 times.
3805 for (const MutationRecord &record : records) {
664 // Reentrant replay must not recover a reservation still owned by this
665 // live transport. Disconnect releases ownership before recovery.
666
2/2
✓ Branch 16 → 17 taken 39 times.
✓ Branch 16 → 18 taken 2846 times.
2885 if (m_mutationsScheduledForDispatch.contains(record.token))
667 39 continue;
668
4/7
✓ Branch 18 → 19 taken 2833 times.
✗ Branch 18 → 21 not taken.
✓ Branch 18 → 46 taken 9 times.
✓ Branch 18 → 48 taken 3 times.
✗ Branch 18 → 67 not taken.
✓ Branch 18 → 70 taken 1 time.
✗ Branch 18 → 74 not taken.
2846 switch (record.phase) {
669 2833 case MutationPhase::Queued:
670
1/1
✓ Branch 19 → 20 taken 2833 times.
2833 dispatchMutation(record);
671 2833 break;
672 ✗ case MutationPhase::Dispatched:
673 ✗ if (record.replayClass == MutationReplayClass::DesiredState) {
674 ✗ m_cache->journal().updateMutationPhase(record.token, MutationPhase::Queued);
675 ✗ if (const auto reset = m_cache->journal().mutation(record.token))
676 ✗ dispatchMutation(*reset);
677 ✗ } else if (!m_ambiguousReconciliationQueue.contains(record.token)) {
678 ✗ m_cache->journal().updateMutationPhase(record.token, MutationPhase::Ambiguous,
679 ✗ tr("Awaiting server verification"));
680 ✗ m_ambiguousReconciliationQueue.enqueue(record.token);
681 }
682 ✗ break;
683 9 case MutationPhase::AwaitingVerification:
684
1/1
✓ Branch 46 → 47 taken 9 times.
9 requestMutationVerification(record);
685 9 break;
686 3 case MutationPhase::Ambiguous:
687
1/2
✗ Branch 48 → 49 not taken.
✓ Branch 48 → 63 taken 3 times.
3 if (record.replayClass == MutationReplayClass::DesiredState) {
688 ✗ m_cache->journal().updateMutationPhase(record.token, MutationPhase::Queued);
689 ✗ if (const auto reset = m_cache->journal().mutation(record.token))
690 ✗ dispatchMutation(*reset);
691
1/2
✓ Branch 64 → 65 taken 3 times.
✗ Branch 64 → 66 not taken.
3 } else if (!m_ambiguousReconciliationQueue.contains(record.token)) {
692
1/1
✓ Branch 65 → 66 taken 3 times.
3 m_ambiguousReconciliationQueue.enqueue(record.token);
693 }
694 3 break;
695 ✗ case MutationPhase::Confirmed:
696 ✗ m_cache->journal().removeMutation(record.token);
697 ✗ break;
698 1 case MutationPhase::Failed:
699 case MutationPhase::Quarantined:
700
1/1
✓ Branch 71 → 72 taken 1 time.
1 m_deps.pendingActionChanged(record.token, {});
701 1 break;
702 }
703 }
704
1/1
✓ Branch 77 → 78 taken 920 times.
920 reconcileNextAmbiguousMutation();
705 920 }
706
707 26 void MailMutationPipeline::onMutationDispatched(qint64 token) {
708 // Keep this token as the single active journal transaction until its
709 // terminal response and (for desired-state mutations) authoritative flag
710 // readback. Releasing it here would allow another folder SELECT to overtake
711 // a serialized UID command on the shared secondary connection.
712
1/1
✓ Branch 5 → 6 taken 26 times.
26 m_cache->journal().updateMutationPhase(token, MutationPhase::Dispatched);
713 26 MailJD::Performance::MutationPerformanceTracker::instance()
714 26 .mutationDispatched(token);
715 26 }
716
717 44 void MailMutationPipeline::onMutationCompleted(
718 qint64 token, int outcomeInt, const QString &error,
719 const QString &uidPlusEvidence) {
720
1/1
✓ Branch 3 → 4 taken 44 times.
44 const auto stored = m_cache->journal().mutation(token);
721
2/2
✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 8 taken 42 times.
44 if (!stored) {
722 // F4.3: the record is already gone; freeing the single-flight slot must
723 // also re-drive the journal instead of waiting for an unrelated event.
724
1/1
✓ Branch 6 → 7 taken 2 times.
2 releaseDispatchReservation(token);
725 2 return;
726 }
727 42 const MutationRecord record = *stored;
728 42 const auto outcome = static_cast<ImapService::MutationOutcome>(outcomeInt);
729 auto &performanceTracker =
730
1/1
✓ Branch 10 → 11 taken 42 times.
42 MailJD::Performance::MutationPerformanceTracker::instance();
731
1/1
✓ Branch 11 → 12 taken 42 times.
42 performanceTracker.protocolFinished(token);
732
733
2/2
✓ Branch 12 → 13 taken 26 times.
✓ Branch 12 → 55 taken 16 times.
42 if (outcome == ImapService::MutationOutcome::Confirmed) {
734
2/2
✓ Branch 13 → 14 taken 7 times.
✓ Branch 13 → 27 taken 19 times.
26 if (record.replayClass == MutationReplayClass::DesiredState) {
735
1/1
✓ Branch 14 → 15 taken 7 times.
7 performanceTracker.reconciliationStarted(token);
736
1/1
✓ Branch 17 → 18 taken 7 times.
7 m_cache->journal().updateMutationPhase(token, MutationPhase::AwaitingVerification,
737 {}, uidPlusEvidence);
738
2/3
✓ Branch 20 → 21 taken 7 times.
✓ Branch 22 → 23 taken 7 times.
✗ Branch 22 → 25 not taken.
7 if (const auto awaiting = m_cache->journal().mutation(token))
739
1/1
✓ Branch 24 → 25 taken 7 times.
7 requestMutationVerification(*awaiting);
740 } else {
741 // A confirmed location-changing operation owns its source identity even
742 // when the visible folder generation changed while the command was in
743 // flight (notably the Message-ID based undo flow). Aggregate
744 // messageMoved signals cannot safely infer that folder and are allowed
745 // to be ignored; the durable record is the authoritative cleanup key.
746
1/1
✓ Branch 27 → 28 taken 19 times.
19 performanceTracker.reconciliationStarted(token);
747
2/2
✓ Branch 28 → 29 taken 7 times.
✓ Branch 28 → 30 taken 12 times.
19 if (record.kind == MutationKind::Move ||
748
2/2
✓ Branch 29 → 30 taken 2 times.
✓ Branch 29 → 35 taken 5 times.
7 record.kind == MutationKind::Undo) {
749
1/1
✓ Branch 30 → 31 taken 14 times.
14 retireSourceFlagVerification(record);
750
1/1
✓ Branch 31 → 32 taken 14 times.
14 m_cache->removeHeader(record.mailId.folderId, record.mailId.uid);
751
1/1
✓ Branch 32 → 33 taken 14 times.
14 m_model->removeByUid(record.mailId.uid, record.mailId.folderId);
752
2/2
✓ Branch 33 → 34 taken 5 times.
✓ Branch 33 → 35 taken 9 times.
14 if (m_threadModel)
753 5 m_threadModel->removeByUid(record.mailId.uid,
754
1/1
✓ Branch 34 → 35 taken 5 times.
5 record.mailId.folderId);
755 }
756
1/1
✓ Branch 37 → 38 taken 19 times.
19 m_cache->journal().updateMutationPhase(token, MutationPhase::Confirmed, {},
757 uidPlusEvidence);
758
1/1
✓ Branch 40 → 41 taken 19 times.
19 m_cache->journal().removeMutation(token);
759
2/2
✓ Branch 41 → 42 taken 3 times.
✓ Branch 41 → 44 taken 16 times.
19 if (record.kind == MutationKind::Append) {
760
2/2
✓ Branch 42 → 43 taken 3 times.
✓ Branch 43 → 44 taken 3 times.
3 m_deps.appendMutationCompleted(token, record.targetFolder,
761 uidPlusEvidence.toLongLong());
762 }
763
2/2
✓ Branch 44 → 45 taken 2 times.
✓ Branch 44 → 49 taken 17 times.
19 if (record.kind == MutationKind::Undo)
764
2/2
✓ Branch 45 → 46 taken 2 times.
✓ Branch 46 → 47 taken 2 times.
2 m_deps.statusMessage(tr("Undo completed"));
765
1/1
✓ Branch 49 → 50 taken 19 times.
19 m_flagIntents.clearMove(record.mailId);
766
1/1
✓ Branch 50 → 51 taken 19 times.
19 m_mutationsScheduledForDispatch.remove(token);
767
1/1
✓ Branch 51 → 52 taken 19 times.
19 performanceTracker.reconciliationFinished(token, true);
768
1/1
✓ Branch 52 → 53 taken 19 times.
19 QTimer::singleShot(0, this, &MailMutationPipeline::replayMutationJournal);
769 }
770 26 return;
771 }
772
773
3/4
✓ Branch 55 → 56 taken 12 times.
✓ Branch 55 → 62 taken 4 times.
✗ Branch 60 → 61 not taken.
✓ Branch 60 → 62 taken 12 times.
28 if (outcome == ImapService::MutationOutcome::Rejected &&
774
5/6
✓ Branch 63 → 64 taken 12 times.
✓ Branch 63 → 65 taken 4 times.
✓ Branch 65 → 66 taken 12 times.
✓ Branch 65 → 67 taken 4 times.
✗ Branch 67 → 68 not taken.
✓ Branch 67 → 77 taken 16 times.
28 error == QStringLiteral("Mailbox changed before dispatch")) {
775 // Nothing reached the wire: another SELECT overtook the queued UID
776 // command. Replay reselects the record's own mailbox.
777 ✗ m_cache->journal().updateMutationPhase(token, MutationPhase::Queued);
778 ✗ m_mutationsScheduledForDispatch.remove(token);
779 ✗ QTimer::singleShot(0, this, &MailMutationPipeline::replayMutationJournal);
780 ✗ return;
781 }
782
783
2/2
✓ Branch 78 → 79 taken 3 times.
✓ Branch 78 → 80 taken 12 times.
15 if (outcome == ImapService::MutationOutcome::CancelledBeforeDispatch ||
784 3 (outcome == ImapService::MutationOutcome::Ambiguous &&
785
6/6
✓ Branch 77 → 78 taken 15 times.
✓ Branch 77 → 86 taken 1 time.
✓ Branch 79 → 80 taken 2 times.
✓ Branch 79 → 86 taken 1 time.
✓ Branch 80 → 81 taken 12 times.
✓ Branch 80 → 87 taken 2 times.
43 record.replayClass == MutationReplayClass::DesiredState) ||
786
2/2
✓ Branch 85 → 86 taken 1 time.
✓ Branch 85 → 87 taken 11 times.
12 (outcome == ImapService::MutationOutcome::Rejected &&
787
6/6
✓ Branch 88 → 89 taken 12 times.
✓ Branch 88 → 90 taken 4 times.
✓ Branch 90 → 91 taken 12 times.
✓ Branch 90 → 92 taken 4 times.
✓ Branch 92 → 93 taken 3 times.
✓ Branch 92 → 106 taken 13 times.
28 error == QStringLiteral("No folder selected"))) {
788
1/1
✓ Branch 96 → 97 taken 3 times.
6 m_cache->journal().updateMutationPhase(token, MutationPhase::Queued,
789
1/1
✓ Branch 95 → 96 taken 3 times.
6 tr("Queued until the mailbox reconnects"));
790
2/2
✓ Branch 99 → 100 taken 3 times.
✓ Branch 100 → 101 taken 3 times.
3 m_deps.statusMessage(tr("Action queued until the mailbox reconnects"));
791
1/1
✓ Branch 102 → 103 taken 3 times.
3 m_mutationsScheduledForDispatch.remove(token);
792
1/1
✓ Branch 103 → 104 taken 3 times.
3 performanceTracker.mutationQueuedOffline(token);
793 // F4.3: the requeued record needs an explicit re-drive; without it the
794 // single-flight pipeline idles until an unrelated journal event fires.
795
1/1
✓ Branch 104 → 105 taken 3 times.
3 QTimer::singleShot(0, this, &MailMutationPipeline::replayMutationJournal);
796 3 return;
797 }
798
799
2/2
✓ Branch 106 → 107 taken 2 times.
✓ Branch 106 → 119 taken 11 times.
13 if (outcome == ImapService::MutationOutcome::Ambiguous) {
800
1/1
✓ Branch 107 → 108 taken 2 times.
2 performanceTracker.reconciliationStarted(token);
801
1/1
✓ Branch 110 → 111 taken 2 times.
4 m_cache->journal().updateMutationPhase(token, MutationPhase::Ambiguous,
802
1/1
✓ Branch 109 → 110 taken 2 times.
4 tr("Awaiting server verification"),
803 uidPlusEvidence);
804
1/1
✓ Branch 112 → 113 taken 2 times.
2 reportSlowVerification(record);
805
1/2
✓ Branch 114 → 115 taken 2 times.
✗ Branch 114 → 116 not taken.
2 if (!m_ambiguousReconciliationQueue.contains(token))
806
1/1
✓ Branch 115 → 116 taken 2 times.
2 m_ambiguousReconciliationQueue.enqueue(token);
807
1/1
✓ Branch 116 → 117 taken 2 times.
2 m_mutationsScheduledForDispatch.remove(token);
808
1/1
✓ Branch 117 → 118 taken 2 times.
2 reconcileNextAmbiguousMutation();
809 2 return;
810 }
811
812
1/1
✓ Branch 120 → 121 taken 11 times.
11 m_cache->journal().updateMutationPhase(token, MutationPhase::Failed, error,
813 uidPlusEvidence);
814
1/1
✓ Branch 121 → 122 taken 11 times.
11 performanceTracker.reconciliationStarted(token);
815
2/2
✓ Branch 122 → 123 taken 8 times.
✓ Branch 122 → 124 taken 3 times.
11 if (record.replayClass == MutationReplayClass::DesiredState) {
816
1/1
✓ Branch 123 → 146 taken 8 times.
8 rollbackDesiredMutation(record);
817 } else {
818
2/2
✓ Branch 124 → 125 taken 1 time.
✓ Branch 124 → 126 taken 2 times.
3 if (record.kind == MutationKind::Append) {
819
1/1
✓ Branch 125 → 126 taken 1 time.
1 m_deps.appendMutationFailed(token, error);
820 }
821
1/1
✓ Branch 126 → 127 taken 3 times.
3 m_flagIntents.clearMove(record.mailId);
822
2/2
✓ Branch 129 → 130 taken 2 times.
✓ Branch 129 → 144 taken 1 time.
3 if (const auto header = m_cache->header(record.mailId.folderId,
823
1/1
✓ Branch 127 → 128 taken 3 times.
3 record.mailId.uid)) {
824
2/3
✓ Branch 130 → 131 taken 2 times.
✓ Branch 131 → 132 taken 2 times.
✗ Branch 131 → 141 not taken.
2 if (m_model->rowForUid(record.mailId.uid, record.mailId.folderId) < 0)
825
3/5
✓ Branch 135 → 136 taken 2 times.
✓ Branch 138 → 139 taken 2 times.
✓ Branch 138 → 140 taken 2 times.
✗ Branch 207 → 208 not taken.
✗ Branch 207 → 209 not taken.
4 m_model->appendHeaders({*header});
826
1/2
✓ Branch 141 → 142 taken 2 times.
✗ Branch 141 → 144 not taken.
2 if (m_threadModel)
827
1/1
✓ Branch 143 → 144 taken 2 times.
2 m_threadModel->setHeaders(m_model->allHeaders());
828 3 }
829 }
830 // Failed copies retain their exact payload for an explicit APPEND-only retry.
831 // SMTP must never be repeated to recover a rejected Sent copy.
832
3/3
✓ Branch 146 → 147 taken 10 times.
✓ Branch 146 → 149 taken 1 time.
✓ Branch 148 → 149 taken 10 times.
11 if (record.kind != MutationKind::Append) m_cache->journal().removeMutation(token);
833
1/1
✓ Branch 149 → 150 taken 11 times.
11 m_mutationsScheduledForDispatch.remove(token);
834
1/1
✓ Branch 150 → 151 taken 11 times.
11 performanceTracker.reconciliationFinished(token, false);
835
1/1
✓ Branch 151 → 152 taken 11 times.
11 QTimer::singleShot(0, this, &MailMutationPipeline::replayMutationJournal);
836
1/1
✓ Branch 153 → 154 taken 11 times.
11 m_deps.pendingActionChanged(token, {});
837
3/3
✓ Branch 155 → 156 taken 11 times.
✓ Branch 156 → 157 taken 11 times.
✓ Branch 157 → 158 taken 11 times.
11 m_deps.actionError(tr("Action failed in %1: %2").arg(record.folderPath, error));
838 77 }
839
840 8 void MailMutationPipeline::rollbackDesiredMutation(const MutationRecord &record) {
841
1/1
✓ Branch 2 → 3 taken 8 times.
8 const auto header = m_cache->header(record.mailId.folderId, record.mailId.uid);
842
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 8 times.
8 if (!header)
843 ✗ return;
844
2/2
✓ Branch 6 → 7 taken 4 times.
✓ Branch 6 → 8 taken 4 times.
8 if (record.kind == MutationKind::Seen ||
845
2/2
✓ Branch 7 → 8 taken 2 times.
✓ Branch 7 → 21 taken 2 times.
4 record.kind == MutationKind::Flagged) {
846
2/2
✓ Branch 8 → 9 taken 4 times.
✓ Branch 8 → 10 taken 2 times.
6 const quint32 bit = record.kind == MutationKind::Seen ? MailFlag::Seen
847 : MailFlag::Flagged;
848
2/2
✓ Branch 11 → 12 taken 4 times.
✓ Branch 11 → 14 taken 2 times.
6 const quint32 flags = record.desiredState ? (header->flags & ~bit)
849 2 : (header->flags | bit);
850
1/1
✓ Branch 16 → 17 taken 6 times.
6 m_cache->updateFlags(record.mailId.folderId, record.mailId.uid, flags);
851
1/1
✓ Branch 17 → 18 taken 6 times.
6 m_model->updateFlags(record.mailId.uid, flags, record.mailId.folderId);
852
1/2
✓ Branch 18 → 19 taken 6 times.
✗ Branch 18 → 20 not taken.
6 if (m_threadModel)
853 6 m_threadModel->updateFlags(record.mailId.uid, flags,
854
1/1
✓ Branch 19 → 20 taken 6 times.
6 record.mailId.folderId);
855
1/2
✓ Branch 21 → 22 taken 2 times.
✗ Branch 21 → 25 not taken.
8 } else if (record.kind == MutationKind::Keyword) {
856
2/2
✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 24 taken 1 time.
2 if (record.desiredState)
857 1 m_cache->removeLabel(record.mailId.folderId, record.mailId.uid,
858
1/1
✓ Branch 23 → 25 taken 1 time.
1 record.flag);
859 else
860 1 m_cache->addLabel(record.mailId.folderId, record.mailId.uid,
861
1/1
✓ Branch 24 → 25 taken 1 time.
1 record.flag);
862 }
863
1/1
✓ Branch 25 → 26 taken 8 times.
8 m_flagIntents.clearFlag(record.mailId);
864
2/2
✓ Branch 26 → 27 taken 4 times.
✓ Branch 26 → 32 taken 4 times.
8 if (record.kind == MutationKind::Seen) {
865
1/1
✓ Branch 27 → 28 taken 4 times.
4 const int unread = m_cache->unreadCount(record.mailId.folderId);
866
1/1
✓ Branch 28 → 29 taken 4 times.
4 m_flagIntents.setLastPolledUnread(record.folderPath, unread);
867
1/1
✓ Branch 29 → 30 taken 4 times.
4 m_cache->storeBadge(record.mailId.folderId, unread);
868
1/1
✓ Branch 30 → 31 taken 4 times.
4 m_deps.cachedUnreadCountChanged(record.folderPath, unread);
869
1/1
✓ Branch 31 → 32 taken 4 times.
4 m_deps.unreadCountChanged(record.folderPath, unread);
870 }
871 8 }
872
873 19 void MailMutationPipeline::reportSlowVerification(const MutationRecord &record) {
874
2/2
✓ Branch 3 → 4 taken 11 times.
✓ Branch 3 → 5 taken 8 times.
19 if (m_slowVerificationReports.contains(record.token)) return;
875
1/1
✓ Branch 5 → 6 taken 8 times.
8 m_slowVerificationReports.insert(record.token);
876 // Verification is ordinary background work. Only an unresolved operation
877 // after a readable grace period warrants interrupting the user.
878 8 const auto generation = m_mutationVerificationGeneration;
879 8 const auto database = m_cache->databasePath();
880
1/3
✓ Branch 9 → 10 taken 8 times.
✗ Branch 18 → 19 not taken.
✗ Branch 18 → 20 not taken.
8 QTimer::singleShot(10000, this, [this, record, generation, database] {
881 4 if (generation != m_mutationVerificationGeneration ||
882
5/10
✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 8 not taken.
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 8 not taken.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
✗ Branch 10 → 12 not taken.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 1 time.
1 database != m_cache->databasePath() || record.mailId.accountId != m_folder.accountId) return;
883
1/1
✓ Branch 15 → 16 taken 1 time.
1 const auto current = m_cache->journal().mutation(record.token);
884
3/6
✓ Branch 17 → 18 taken 1 time.
✗ Branch 17 → 22 not taken.
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 23 taken 1 time.
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 1 time.
1 if (!current || (current->phase != MutationPhase::AwaitingVerification &&
885 ✗ current->phase != MutationPhase::Ambiguous)) return;
886 // P90.15: interrupt once per operation, never per folder in a burst.
887 // While several operations are unresolved the report aggregates to a
888 // single count line instead of cycling per-folder texts.
889 1 int unresolved = 0;
890
5/5
✓ Branch 27 → 28 taken 1 time.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 29 → 30 taken 1 time.
✓ Branch 39 → 31 taken 1 time.
✓ Branch 39 → 40 taken 1 time.
2 for (const auto &pending : m_cache->journal().pendingMutations(m_folder.accountId))
891
1/2
✗ Branch 32 → 33 not taken.
✓ Branch 32 → 36 taken 1 time.
1 if (pending.phase == MutationPhase::AwaitingVerification ||
892 ✗ pending.phase == MutationPhase::Ambiguous ||
893 ✗ pending.phase == MutationPhase::Queued || pending.phase == MutationPhase::Dispatched)
894 2 ++unresolved;
895
1/2
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 46 taken 1 time.
1 if (unresolved > 1) {
896 ✗ m_deps.pendingActionChanged(record.token,
897 ✗ tr("%n change(s) not yet confirmed by the server", nullptr, unresolved));
898 ✗ return;
899 }
900
2/2
✓ Branch 46 → 47 taken 1 time.
✓ Branch 49 → 50 taken 1 time.
2 m_deps.pendingActionChanged(record.token, tr("Change in %1 not yet confirmed by the server")
901
1/1
✓ Branch 48 → 49 taken 1 time.
2 .arg(record.folderPath));
902
1/2
✓ Branch 54 → 55 taken 1 time.
✗ Branch 54 → 57 not taken.
1 });
903 8 }
904
905 17 void MailMutationPipeline::retireSourceFlagVerification(const MutationRecord &record) {
906
1/1
✓ Branch 3 → 4 taken 17 times.
17 const auto tokens = m_cache->journal().retireSourceFlagMutations(record.mailId, record.uidValidity);
907
2/2
✓ Branch 12 → 6 taken 3 times.
✓ Branch 12 → 13 taken 17 times.
20 for (qint64 token : tokens) {
908
1/1
✓ Branch 7 → 8 taken 3 times.
3 m_mutationsScheduledForDispatch.remove(token);
909
1/1
✓ Branch 8 → 9 taken 3 times.
3 MailJD::Performance::MutationPerformanceTracker::instance()
910
1/1
✓ Branch 9 → 10 taken 3 times.
3 .reconciliationSuperseded(token);
911 }
912
3/3
✓ Branch 14 → 15 taken 3 times.
✓ Branch 14 → 16 taken 14 times.
✓ Branch 15 → 16 taken 3 times.
17 if (!tokens.isEmpty()) m_flagIntents.clearFlag(record.mailId);
913 17 }
914
915 12 void MailMutationPipeline::deferMutationVerification(const MutationRecord &record) {
916 // A failed readback owns no live wire transaction. Retain the unresolved
917 // journal intent, but release the single-flight slot for unrelated actions.
918
1/1
✓ Branch 2 → 3 taken 12 times.
12 m_mutationsScheduledForVerification.remove(record.token);
919 12 const bool alreadyDeferred = m_deferredMutationVerifications.contains(record.token);
920
1/1
✓ Branch 4 → 5 taken 12 times.
12 m_deferredMutationVerifications.insert(record.token);
921
1/1
✓ Branch 5 → 6 taken 12 times.
12 releaseDispatchReservation(record.token);
922
6/6
✓ Branch 6 → 7 taken 7 times.
✓ Branch 6 → 9 taken 5 times.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 10 taken 6 times.
✓ Branch 11 → 12 taken 6 times.
✓ Branch 11 → 13 taken 6 times.
12 if (alreadyDeferred || m_mutationVerificationAttempts.value(record.token) >= 3) return;
923 6 const auto generation = m_mutationVerificationGeneration;
924 6 const auto database = m_cache->databasePath();
925
1/3
✓ Branch 16 → 17 taken 6 times.
✗ Branch 25 → 26 not taken.
✗ Branch 25 → 27 not taken.
6 QTimer::singleShot(1000, this, [this, record, generation, database] {
926 8 if (generation != m_mutationVerificationGeneration ||
927
5/10
✓ Branch 2 → 3 taken 2 times.
✗ Branch 2 → 8 not taken.
✓ Branch 5 → 6 taken 2 times.
✗ Branch 5 → 8 not taken.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 2 times.
✓ Branch 10 → 11 taken 2 times.
✗ Branch 10 → 12 not taken.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 2 times.
2 database != m_cache->databasePath() || record.mailId.accountId != m_folder.accountId) return;
928
1/1
✓ Branch 14 → 15 taken 2 times.
2 m_deferredMutationVerifications.remove(record.token);
929
1/1
✓ Branch 16 → 17 taken 2 times.
2 const auto current = m_cache->journal().mutation(record.token);
930
4/8
✓ Branch 18 → 19 taken 2 times.
✗ Branch 18 → 25 not taken.
✓ Branch 20 → 21 taken 2 times.
✗ Branch 20 → 25 not taken.
✓ Branch 23 → 24 taken 2 times.
✗ Branch 23 → 25 not taken.
✓ Branch 26 → 27 taken 2 times.
✗ Branch 26 → 29 not taken.
4 if (current && current->phase == MutationPhase::AwaitingVerification &&
931 2 current->mailId == record.mailId)
932
1/1
✓ Branch 28 → 29 taken 2 times.
2 requestMutationVerification(*current);
933 2 });
934 6 }
935
936 18 void MailMutationPipeline::requestMutationVerification(
937 const MutationRecord &record) {
938
1/2
✓ Branch 5 → 6 taken 18 times.
✗ Branch 5 → 10 not taken.
36 if (record.mailId.accountId != m_folder.accountId ||
939
2/2
✓ Branch 7 → 8 taken 12 times.
✓ Branch 7 → 10 taken 6 times.
36 m_mutationsScheduledForVerification.contains(record.token) ||
940
4/6
✓ Branch 3 → 4 taken 18 times.
✗ Branch 3 → 10 not taken.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 12 times.
✓ Branch 12 → 13 taken 6 times.
✓ Branch 12 → 14 taken 12 times.
54 m_deferredMutationVerifications.contains(record.token) ||
941 12 m_mutationVerificationAttempts.value(record.token) >= 3)
942 16 return;
943 12 const auto generation = m_mutationVerificationGeneration;
944 12 const auto database = m_cache->databasePath();
945 5 auto fetchWith = [this, record, generation, database](ImapService *service) {
946
4/8
✓ Branch 2 → 3 taken 5 times.
✗ Branch 2 → 8 not taken.
✓ Branch 5 → 6 taken 5 times.
✗ Branch 5 → 8 not taken.
✓ Branch 10 → 11 taken 5 times.
✗ Branch 10 → 12 not taken.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 5 times.
10 if (generation != m_mutationVerificationGeneration || database != m_cache->databasePath() ||
947
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 5 times.
5 record.mailId.accountId != m_folder.accountId) return;
948
1/1
✓ Branch 15 → 16 taken 5 times.
5 const auto current = m_cache->journal().mutation(record.token);
949
1/2
✓ Branch 19 → 20 taken 5 times.
✗ Branch 19 → 25 not taken.
10 if (!current || current->phase != MutationPhase::AwaitingVerification ||
950
4/8
✓ Branch 17 → 18 taken 5 times.
✗ Branch 17 → 25 not taken.
✓ Branch 22 → 23 taken 5 times.
✗ Branch 22 → 25 not taken.
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 5 times.
✗ Branch 27 → 28 not taken.
✓ Branch 27 → 29 taken 5 times.
10 current->mailId != record.mailId || current->uidValidity != record.uidValidity)
951 ✗ return;
952
1/1
✓ Branch 29 → 30 taken 5 times.
5 m_mutationsScheduledForVerification.insert(record.token);
953
1/1
✓ Branch 30 → 31 taken 5 times.
5 ++m_mutationVerificationAttempts[record.token];
954
1/1
✓ Branch 31 → 32 taken 5 times.
5 reportSlowVerification(record);
955 // F-38: read back only the changed UID. The previous whole-folder
956 // UID FETCH 1:* returned one full flag snapshot (838 kB on a
957 // 20,000-message folder) per flag change, which multiplied into
958 // minutes for mark-folder-read and selection actions.
959
1/1
✓ Branch 32 → 33 taken 5 times.
5 service->fetchVerificationFlags(record.mailId.uid);
960
1/2
✓ Branch 35 → 36 taken 5 times.
✗ Branch 35 → 38 not taken.
17 };
961
2/2
✓ Branch 18 → 19 taken 10 times.
✓ Branch 18 → 38 taken 2 times.
12 if (m_imapConfig.host.isEmpty()) {
962
1/2
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 27 taken 6 times.
6 if ((m_imap->state() == ImapService::State::Selected ||
963
3/4
✓ Branch 20 → 21 taken 6 times.
✓ Branch 20 → 23 taken 4 times.
✓ Branch 25 → 26 taken 4 times.
✗ Branch 25 → 27 not taken.
20 m_imap->state() == ImapService::State::Idling) &&
964
4/4
✓ Branch 28 → 29 taken 4 times.
✓ Branch 28 → 30 taken 6 times.
✓ Branch 30 → 31 taken 4 times.
✓ Branch 30 → 37 taken 6 times.
14 m_imap->selectedFolder() == record.folderPath) {
965
1/1
✓ Branch 33 → 34 taken 4 times.
4 m_imap->executeAfterIdle(
966
1/3
✓ Branch 32 → 33 taken 4 times.
✗ Branch 96 → 97 not taken.
✗ Branch 96 → 98 not taken.
12 [fetchWith, service = m_imap]() { fetchWith(service); });
967 }
968 10 return;
969 }
970
1/1
✓ Branch 38 → 39 taken 2 times.
2 m_deps.ensureBodyConnection();
971
1/2
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 41 taken 2 times.
2 if (!m_bodyImap)
972 ✗ return;
973
1/4
✗ Branch 42 → 43 not taken.
✓ Branch 42 → 47 taken 2 times.
✗ Branch 45 → 46 not taken.
✗ Branch 45 → 47 not taken.
2 if (m_bodyImap->state() == ImapService::State::Selected &&
974
2/4
✗ Branch 48 → 49 not taken.
✓ Branch 48 → 50 taken 2 times.
✗ Branch 50 → 51 not taken.
✓ Branch 50 → 53 taken 2 times.
2 m_bodyImap->selectedFolder() == record.folderPath) {
975 ✗ fetchWith(m_bodyImap);
976 ✗ return;
977 }
978
2/6
✗ Branch 54 → 55 not taken.
✓ Branch 54 → 58 taken 2 times.
✗ Branch 56 → 57 not taken.
✗ Branch 56 → 58 not taken.
✗ Branch 59 → 60 not taken.
✓ Branch 59 → 61 taken 2 times.
2 if (m_bodyImap->state() != ImapService::State::Authenticated &&
979 ✗ m_bodyImap->state() != ImapService::State::Selected)
980 ✗ return;
981
1/1
✓ Branch 61 → 62 taken 2 times.
2 m_mutationsScheduledForVerification.insert(record.token);
982 // S106-186: the readback fence follows reserveAndSelectForDispatch. A
983 // single-shot connection was consumed by a foreign folderSelected (body
984 // fetch, navigation) and never fired for a rejected SELECT; the token then
985 // kept the verification slot and the single-flight dispatch reservation,
986 // and no other journal record was dispatched until the connection dropped.
987
1/1
✓ Branch 62 → 63 taken 2 times.
2 const auto connection = std::make_shared<QMetaObject::Connection>();
988
1/1
✓ Branch 63 → 64 taken 2 times.
2 const auto fenced = std::make_shared<bool>(false);
989 4 *connection = connect(m_bodyImap, &ImapService::folderSelected, this,
990
1/7
✓ Branch 68 → 69 taken 2 times.
✗ Branch 105 → 106 not taken.
✗ Branch 105 → 107 not taken.
✗ Branch 108 → 109 not taken.
✗ Branch 108 → 110 not taken.
✗ Branch 111 → 112 not taken.
✗ Branch 111 → 113 not taken.
4 [this, record, fetchWith, connection,
991 fenced](const QString &path, int, quint32, quint64) {
992
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 1 time.
2 if (path != record.folderPath)
993 1 return;
994 1 *fenced = true;
995 1 disconnect(*connection);
996 1 fetchWith(m_bodyImap);
997 2 });
998 // Watchdog: a SELECT that never confirms hands the slot back. The attempt
999 // counts against the readback bound so a folder the server keeps rejecting
1000 // stops being retried after three rounds (the record stays pending).
1001
1/5
✓ Branch 76 → 77 taken 2 times.
✗ Branch 118 → 119 not taken.
✗ Branch 118 → 120 not taken.
✗ Branch 121 → 122 not taken.
✗ Branch 121 → 123 not taken.
2 QTimer::singleShot(m_selectFenceTimeoutMs, this, [this, record, connection, fenced] {
1002
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 1 time.
2 if (*fenced)
1003 1 return;
1004 1 disconnect(*connection);
1005
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 1 time.
1 if (!m_mutationsScheduledForVerification.contains(record.token))
1006 ✗ return;
1007
5/5
✓ Branch 10 → 11 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 15 → 16 taken 1 time.
✓ Branch 23 → 12 taken 1 time.
✓ Branch 23 → 24 taken 1 time.
2 qCWarning(lcController) << "verification SELECT fence timed out"
1008
4/4
✓ Branch 16 → 17 taken 1 time.
✓ Branch 17 → 18 taken 1 time.
✓ Branch 18 → 19 taken 1 time.
✓ Branch 19 → 20 taken 1 time.
1 << "token" << record.token << "folder" << record.folderPath;
1009 1 ++m_mutationVerificationAttempts[record.token];
1010 1 deferMutationVerification(record);
1011 });
1012
1/1
✓ Branch 78 → 79 taken 2 times.
2 m_bodyImap->selectFolder(record.folderPath);
1013 22 }
1014
1015 23 void MailMutationPipeline::onFlagSnapshotsReceived(
1016 const QList<ImapFlagSnapshot> &snapshots, const QString &folderPath) {
1017 23 applyFlagSnapshots(qobject_cast<ImapService *>(sender()), snapshots, folderPath, 0);
1018 23 }
1019
1020 // S106-195: a verification readback asked for one UID — its answer (or the
1021 // absence of one) is evidence about that UID's records only, never about
1022 // other records of the folder awaiting their own readback.
1023 6 void MailMutationPipeline::onVerificationFlagsReceived(
1024 const QList<ImapFlagSnapshot> &snapshots, const QString &folderPath, qint64 requestedUid) {
1025 6 applyFlagSnapshots(qobject_cast<ImapService *>(sender()), snapshots, folderPath,
1026 requestedUid);
1027 6 }
1028
1029 29 void MailMutationPipeline::applyFlagSnapshots(ImapService *origin,
1030 const QList<ImapFlagSnapshot> &snapshots, const QString &folderPath, qint64 onlyUid) {
1031 // Foreground folder sync may have captured flags before the secondary
1032 // connection's STORE. It cannot confirm or reject that transaction.
1033
2/2
✓ Branch 3 → 4 taken 25 times.
✓ Branch 3 → 5 taken 4 times.
29 auto *owner = m_imapConfig.host.isEmpty() ? m_imap : m_bodyImap;
1034
4/4
✓ Branch 6 → 7 taken 8 times.
✓ Branch 6 → 9 taken 21 times.
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 9 taken 7 times.
29 if (origin && origin != owner) return;
1035
1/1
✓ Branch 10 → 11 taken 28 times.
28 const auto records = m_cache->journal().pendingMutations(m_folder.accountId);
1036 28 QHash<MailId, qint64> latestDesiredToken;
1037
2/2
✓ Branch 24 → 13 taken 49 times.
✓ Branch 24 → 25 taken 28 times.
77 for (const auto &pending : records) {
1038
2/2
✓ Branch 14 → 15 taken 43 times.
✓ Branch 14 → 22 taken 6 times.
49 if (pending.replayClass == MutationReplayClass::DesiredState &&
1039
2/2
✓ Branch 15 → 16 taken 42 times.
✓ Branch 15 → 22 taken 1 time.
43 pending.phase != MutationPhase::Confirmed &&
1040
2/2
✓ Branch 16 → 17 taken 41 times.
✓ Branch 16 → 22 taken 1 time.
42 pending.phase != MutationPhase::Failed &&
1041
2/2
✓ Branch 17 → 18 taken 40 times.
✓ Branch 17 → 22 taken 1 time.
41 pending.phase != MutationPhase::Quarantined)
1042 80 latestDesiredToken[pending.mailId] =
1043
1/1
✓ Branch 20 → 21 taken 40 times.
40 qMax(latestDesiredToken.value(pending.mailId), pending.token);
1044 }
1045
2/2
✓ Branch 214 → 27 taken 49 times.
✓ Branch 214 → 215 taken 28 times.
77 for (const MutationRecord &record : records) {
1046 149 if (record.phase != MutationPhase::AwaitingVerification ||
1047
8/8
✓ Branch 28 → 29 taken 36 times.
✓ Branch 28 → 33 taken 13 times.
✓ Branch 30 → 31 taken 35 times.
✓ Branch 30 → 33 taken 1 time.
✓ Branch 31 → 32 taken 7 times.
✓ Branch 31 → 34 taken 28 times.
✓ Branch 35 → 36 taken 15 times.
✓ Branch 35 → 37 taken 34 times.
56 record.folderPath != folderPath ||
1048
2/2
✓ Branch 32 → 33 taken 1 time.
✓ Branch 32 → 34 taken 6 times.
7 (onlyUid != 0 && record.mailId.uid != onlyUid))
1049 27 continue;
1050
1/1
✓ Branch 39 → 40 taken 34 times.
34 const auto snapshotIt = std::find_if(
1051 30 snapshots.cbegin(), snapshots.cend(), [&record](const auto &snapshot) {
1052 30 return snapshot.uid == record.mailId.uid;
1053 });
1054
2/2
✓ Branch 42 → 43 taken 12 times.
✓ Branch 42 → 68 taken 22 times.
34 if (snapshotIt == snapshots.cend()) {
1055 // Recover journals left behind by older versions: after a confirmed
1056 // MOVE the header is gone, but its earlier flag readback used to stay
1057 // pending forever. A complete owning-transport snapshot and matching
1058 // UID epoch now also establish that the removed source UID is absent.
1059 // Missing snapshot evidence alone never cancels a still-cached message.
1060
2/4
✓ Branch 44 → 45 taken 5 times.
✗ Branch 44 → 52 not taken.
✓ Branch 45 → 46 taken 5 times.
✗ Branch 45 → 52 not taken.
5 if (origin == owner && origin && record.uidValidity != 0 &&
1061
4/5
✓ Branch 43 → 44 taken 5 times.
✓ Branch 43 → 52 taken 7 times.
✓ Branch 46 → 47 taken 5 times.
✓ Branch 47 → 48 taken 5 times.
✗ Branch 47 → 52 not taken.
22 m_cache->uidValidity(record.mailId.folderId) == record.uidValidity &&
1062
7/9
✓ Branch 48 → 49 taken 5 times.
✓ Branch 50 → 51 taken 1 time.
✓ Branch 50 → 52 taken 4 times.
✓ Branch 53 → 54 taken 5 times.
✓ Branch 53 → 55 taken 7 times.
✓ Branch 55 → 56 taken 1 time.
✓ Branch 55 → 59 taken 11 times.
✗ Branch 222 → 223 not taken.
✗ Branch 222 → 224 not taken.
17 !m_cache->header(record.mailId.folderId, record.mailId.uid)) {
1063
1/1
✓ Branch 56 → 57 taken 1 time.
1 retireSourceFlagVerification(record);
1064
1/1
✓ Branch 57 → 58 taken 1 time.
1 QTimer::singleShot(0, this, &MailMutationPipeline::replayMutationJournal);
1065 1 continue;
1066 }
1067 11 m_cache->journal().updateMutationPhase(
1068
1/1
✓ Branch 62 → 63 taken 11 times.
11 record.token, MutationPhase::AwaitingVerification,
1069
1/1
✓ Branch 61 → 62 taken 11 times.
22 tr("Server did not return this message for verification"));
1070
1/1
✓ Branch 65 → 66 taken 11 times.
11 reportSlowVerification(record);
1071
1/1
✓ Branch 66 → 67 taken 11 times.
11 deferMutationVerification(record);
1072 11 continue;
1073 }
1074
1075 22 bool actual = false;
1076
2/2
✓ Branch 68 → 69 taken 14 times.
✓ Branch 68 → 71 taken 8 times.
22 if (record.kind == MutationKind::Seen)
1077 14 actual = snapshotIt->flags & MailFlag::Seen;
1078
2/2
✓ Branch 71 → 72 taken 4 times.
✓ Branch 71 → 74 taken 4 times.
8 else if (record.kind == MutationKind::Flagged)
1079 4 actual = snapshotIt->flags & MailFlag::Flagged;
1080 else
1081
1/1
✓ Branch 78 → 79 taken 4 times.
4 actual = std::any_of(snapshotIt->keywords.cbegin(),
1082 4 snapshotIt->keywords.cend(),
1083 3 [&record](const QString &keyword) {
1084 3 return keyword.compare(record.flag,
1085 3 Qt::CaseInsensitive) == 0;
1086 });
1087
1088 22 const bool matches = actual == record.desiredState;
1089 // A confirmed STORE whose readback differs was overtaken by another
1090 // client or, before queued UID commands were bound to their mailbox, sent
1091 // to another mailbox. Send the desired state once more before accepting
1092 // the server state; the optimistic local state stays until then.
1093
4/4
✓ Branch 80 → 81 taken 3 times.
✓ Branch 80 → 84 taken 19 times.
✓ Branch 82 → 83 taken 2 times.
✓ Branch 82 → 84 taken 1 time.
22 const bool resend = !matches && !m_resentMutations.contains(record.token);
1094
1095 // A readback confirms the command that preceded it. A later queued
1096 // command already owns the optimistic cache/model state for this message.
1097 // Retain that state and its ordinary flag-sync fence until its own readback.
1098
6/6
✓ Branch 85 → 86 taken 20 times.
✓ Branch 85 → 89 taken 2 times.
✓ Branch 87 → 88 taken 17 times.
✓ Branch 87 → 89 taken 3 times.
✓ Branch 90 → 91 taken 17 times.
✓ Branch 90 → 141 taken 5 times.
22 if (!resend && latestDesiredToken.value(record.mailId) <= record.token) {
1099 34 m_cache->updateFlags(record.mailId.folderId, record.mailId.uid,
1100
1/1
✓ Branch 92 → 93 taken 17 times.
17 snapshotIt->flags);
1101
1/1
✓ Branch 94 → 95 taken 17 times.
17 m_model->updateFlags(record.mailId.uid, snapshotIt->flags,
1102 17 record.mailId.folderId);
1103
2/2
✓ Branch 95 → 96 taken 16 times.
✓ Branch 95 → 98 taken 1 time.
17 if (m_threadModel)
1104
1/1
✓ Branch 97 → 98 taken 16 times.
16 m_threadModel->updateFlags(record.mailId.uid, snapshotIt->flags,
1105 16 record.mailId.folderId);
1106
2/2
✓ Branch 98 → 99 taken 3 times.
✓ Branch 98 → 140 taken 14 times.
17 if (record.kind == MutationKind::Keyword) {
1107 const auto cached =
1108
1/1
✓ Branch 99 → 100 taken 3 times.
3 m_cache->header(record.mailId.folderId, record.mailId.uid);
1109
1/2
✓ Branch 101 → 102 taken 3 times.
✗ Branch 101 → 111 not taken.
3 if (cached) {
1110
2/2
✓ Branch 109 → 105 taken 4 times.
✓ Branch 109 → 110 taken 3 times.
7 for (const QString &oldLabel : cached->labels)
1111
1/1
✓ Branch 106 → 107 taken 4 times.
4 m_cache->removeLabel(record.mailId.folderId, record.mailId.uid,
1112 oldLabel);
1113 }
1114
2/2
✓ Branch 118 → 114 taken 4 times.
✓ Branch 118 → 119 taken 3 times.
7 for (const QString &keyword : snapshotIt->keywords)
1115
1/1
✓ Branch 115 → 116 taken 4 times.
4 m_cache->addLabel(record.mailId.folderId, record.mailId.uid, keyword);
1116 6 const int row = m_model->rowForUid(record.mailId.uid,
1117
1/1
✓ Branch 119 → 120 taken 3 times.
3 record.mailId.folderId);
1118
2/2
✓ Branch 120 → 121 taken 2 times.
✓ Branch 120 → 135 taken 1 time.
3 if (row >= 0) {
1119
2/3
✓ Branch 121 → 122 taken 2 times.
✓ Branch 122 → 123 taken 2 times.
✗ Branch 122 → 125 not taken.
2 if (auto *modelHeader = m_model->mutableHeaderAt(row))
1120 2 modelHeader->labels = snapshotIt->keywords;
1121
1/1
✓ Branch 132 → 133 taken 2 times.
6 emit m_model->dataChanged(
1122
1/1
✓ Branch 131 → 132 taken 2 times.
2 m_model->index(row, 0),
1123
2/2
✓ Branch 128 → 129 taken 2 times.
✓ Branch 129 → 130 taken 2 times.
4 m_model->index(row, m_model->columnCount() - 1));
1124 }
1125
1/2
✓ Branch 135 → 136 taken 3 times.
✗ Branch 135 → 138 not taken.
3 if (m_threadModel)
1126
1/1
✓ Branch 137 → 138 taken 3 times.
3 m_threadModel->updateLabels(record.mailId.uid, snapshotIt->keywords,
1127 3 record.mailId.folderId);
1128 3 }
1129
1130
1/1
✓ Branch 140 → 141 taken 17 times.
17 m_flagIntents.clearFlag(record.mailId);
1131 }
1132
1/1
✓ Branch 141 → 142 taken 22 times.
22 m_mutationsScheduledForVerification.remove(record.token);
1133
2/2
✓ Branch 142 → 143 taken 19 times.
✓ Branch 142 → 157 taken 3 times.
22 if (matches) {
1134
1/1
✓ Branch 143 → 144 taken 19 times.
19 m_resentMutations.remove(record.token);
1135
1/1
✓ Branch 144 → 145 taken 19 times.
19 m_deferredMutationVerifications.remove(record.token);
1136
1/1
✓ Branch 145 → 146 taken 19 times.
19 m_mutationVerificationAttempts.remove(record.token);
1137
2/2
✓ Branch 146 → 147 taken 13 times.
✓ Branch 146 → 150 taken 6 times.
19 if (record.kind == MutationKind::Seen) {
1138 39 m_flagIntents.setReadbackFence(record.mailId.folderId, {
1139
1/1
✓ Branch 147 → 148 taken 13 times.
13 MailJD::Performance::PerformanceTrace::monotonicNowNs(),
1140
2/2
✓ Branch 148 → 149 taken 13 times.
✓ Branch 149 → 150 taken 13 times.
13 m_cache->uidValidity(record.mailId.folderId)});
1141 }
1142
1/1
✓ Branch 151 → 152 taken 19 times.
19 m_cache->journal().removeMutation(record.token);
1143
1/1
✓ Branch 152 → 153 taken 19 times.
19 m_mutationsScheduledForDispatch.remove(record.token);
1144
1/1
✓ Branch 153 → 154 taken 19 times.
19 MailJD::Performance::MutationPerformanceTracker::instance()
1145
1/1
✓ Branch 154 → 155 taken 19 times.
19 .reconciliationFinished(record.token, true);
1146
1/1
✓ Branch 155 → 156 taken 19 times.
19 QTimer::singleShot(0, this, &MailMutationPipeline::replayMutationJournal);
1147
2/2
✓ Branch 157 → 158 taken 2 times.
✓ Branch 157 → 186 taken 1 time.
3 } else if (resend) {
1148
5/5
✓ Branch 158 → 159 taken 2 times.
✓ Branch 162 → 163 taken 2 times.
✓ Branch 163 → 164 taken 2 times.
✓ Branch 173 → 160 taken 2 times.
✓ Branch 173 → 174 taken 2 times.
4 qCInfo(lcController) << "Flag readback differs; sending the change again"
1149
3/3
✓ Branch 164 → 165 taken 2 times.
✓ Branch 165 → 166 taken 2 times.
✓ Branch 166 → 167 taken 2 times.
2 << "token" << record.token << "folder"
1150
3/3
✓ Branch 167 → 168 taken 2 times.
✓ Branch 168 → 169 taken 2 times.
✓ Branch 169 → 170 taken 2 times.
2 << record.folderPath << "uid" << record.mailId.uid;
1151
1/1
✓ Branch 174 → 175 taken 2 times.
2 m_resentMutations.insert(record.token);
1152
1/1
✓ Branch 175 → 176 taken 2 times.
2 m_deferredMutationVerifications.remove(record.token);
1153
1/1
✓ Branch 176 → 177 taken 2 times.
2 m_mutationVerificationAttempts.remove(record.token);
1154
1/1
✓ Branch 180 → 181 taken 2 times.
2 m_cache->journal().updateMutationPhase(record.token, MutationPhase::Queued);
1155
1/1
✓ Branch 183 → 184 taken 2 times.
2 MailJD::Performance::MutationPerformanceTracker::instance()
1156
1/1
✓ Branch 184 → 185 taken 2 times.
2 .reconciliationFinished(record.token, false);
1157
1/1
✓ Branch 185 → 210 taken 2 times.
2 releaseDispatchReservation(record.token);
1158 } else {
1159 // The server kept another state after a second confirmed STORE. It is
1160 // authoritative (another client or server policy); the snapshot above
1161 // already restored that state locally, so the intent is retired.
1162
5/5
✓ Branch 186 → 187 taken 1 time.
✓ Branch 190 → 191 taken 1 time.
✓ Branch 191 → 192 taken 1 time.
✓ Branch 201 → 188 taken 1 time.
✓ Branch 201 → 202 taken 1 time.
2 qCWarning(lcController) << "Flag readback still differs after resend;"
1163
1/1
✓ Branch 192 → 193 taken 1 time.
1 << "accepting the server state for token"
1164
3/3
✓ Branch 193 → 194 taken 1 time.
✓ Branch 194 → 195 taken 1 time.
✓ Branch 195 → 196 taken 1 time.
1 << record.token << "folder" << record.folderPath
1165
2/2
✓ Branch 196 → 197 taken 1 time.
✓ Branch 197 → 198 taken 1 time.
1 << "uid" << record.mailId.uid;
1166
1/1
✓ Branch 202 → 203 taken 1 time.
1 m_resentMutations.remove(record.token);
1167
1/1
✓ Branch 204 → 205 taken 1 time.
1 m_cache->journal().removeMutation(record.token);
1168
1/1
✓ Branch 205 → 206 taken 1 time.
1 m_mutationsScheduledForDispatch.remove(record.token);
1169
1/1
✓ Branch 206 → 207 taken 1 time.
1 MailJD::Performance::MutationPerformanceTracker::instance()
1170
1/1
✓ Branch 207 → 208 taken 1 time.
1 .reconciliationFinished(record.token, false);
1171
1/1
✓ Branch 208 → 209 taken 1 time.
1 QTimer::singleShot(0, this, &MailMutationPipeline::replayMutationJournal);
1172 }
1173 }
1174 28 }
1175
1176 928 void MailMutationPipeline::reconcileNextAmbiguousMutation() {
1177
2/2
✓ Branch 2 → 3 taken 4 times.
✓ Branch 2 → 4 taken 924 times.
928 if (m_activeReconciliationToken != 0)
1178 4 return;
1179
2/2
✓ Branch 29 → 5 taken 8 times.
✓ Branch 29 → 30 taken 920 times.
928 while (!m_ambiguousReconciliationQueue.isEmpty()) {
1180
1/1
✓ Branch 5 → 6 taken 8 times.
8 const qint64 token = m_ambiguousReconciliationQueue.dequeue();
1181
1/1
✓ Branch 7 → 8 taken 8 times.
8 const auto record = m_cache->journal().mutation(token);
1182
7/8
✓ Branch 9 → 10 taken 7 times.
✓ Branch 9 → 17 taken 1 time.
✓ Branch 12 → 13 taken 7 times.
✗ Branch 12 → 17 not taken.
✓ Branch 14 → 15 taken 4 times.
✓ Branch 14 → 17 taken 3 times.
✓ Branch 19 → 20 taken 4 times.
✓ Branch 19 → 21 taken 4 times.
12 if (!record || record->mailId.accountId != m_folder.accountId || record->phase != MutationPhase::Ambiguous ||
1183
1/2
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 4 times.
4 record->replayClass != MutationReplayClass::AmbiguousOperation)
1184 4 continue;
1185
1/1
✓ Branch 22 → 23 taken 4 times.
4 reconcileAmbiguousMutation(*record);
1186 4 return;
1187 8 }
1188 }
1189
1190 // reconcileAmbiguousMutation step: a matching Message-ID in the target
1191 // proves the MOVE/COPY/APPEND committed; retire it without repeating it.
1192 3 void MailMutationPipeline::confirmReconciliationInTarget(const MutationRecord &record,
1193 const QList<qint64> &targetUids) {
1194 // A matching Message-ID in the target proves the MOVE/COPY
1195 // committed. Never issue the location-changing command again.
1196
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 2 times.
3 if (record.kind == MutationKind::Move ||
1197
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 9 taken 1 time.
1 record.kind == MutationKind::Undo) {
1198 2 retireSourceFlagVerification(record);
1199 2 m_cache->removeHeader(record.mailId.folderId,
1200 2 record.mailId.uid);
1201 2 m_model->removeByUid(record.mailId.uid,
1202 2 record.mailId.folderId);
1203
1/2
✓ Branch 7 → 8 taken 2 times.
✗ Branch 7 → 9 not taken.
2 if (m_threadModel)
1204 2 m_threadModel->removeByUid(record.mailId.uid,
1205 2 record.mailId.folderId);
1206 }
1207 3 m_cache->journal().removeMutation(record.token);
1208 // S106-194: settled like a wire-confirmed or failed move — the identity
1209 // no longer has a pending move (it used to refuse later moves).
1210 3 m_flagIntents.clearMove(record.mailId);
1211 3 MailJD::Performance::MutationPerformanceTracker::instance()
1212 3 .reconciliationFinished(record.token, true);
1213
2/2
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 21 taken 2 times.
3 if (record.kind == MutationKind::Append) {
1214 2 m_deps.appendMutationCompleted(record.token,
1215 1 record.targetFolder,
1216 1 targetUids.first());
1217
2/2
✓ Branch 17 → 18 taken 1 time.
✓ Branch 18 → 19 taken 1 time.
1 m_deps.statusMessage(tr("Message copy verified on the server"));
1218 } else {
1219
2/2
✓ Branch 21 → 22 taken 2 times.
✓ Branch 22 → 23 taken 2 times.
2 m_deps.statusMessage(tr("Move verified on the server"));
1220 }
1221 3 finishAmbiguousReconciliation();
1222 3 }
1223
1224 // reconcileAmbiguousMutation step: no server copy of an APPEND exists;
1225 // requeue it only when the persisted payload still verifies.
1226 2 void MailMutationPipeline::checkAppendPayloadForReconciliation(const MutationRecord &record) {
1227 // The bounded target search proves no server copy exists.
1228 // Verify the full persisted BLOB on the same serial read
1229 // lane used by dispatch; return only a verdict to the GUI.
1230 2 const QString databasePath = m_cache->databasePath();
1231 2 const quint64 sequence = m_reconciliationSequence;
1232
3/6
✓ Branch 3 → 4 taken 2 times.
✓ Branch 4 → 5 taken 2 times.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 2 times.
✗ Branch 20 → 21 not taken.
✗ Branch 20 → 22 not taken.
2 auto *watcher = new QFutureWatcher<AppendPayloadCheckResult>(this);
1233 2 connect(watcher, &QFutureWatcher<AppendPayloadCheckResult>::finished,
1234
1/5
✓ Branch 9 → 10 taken 2 times.
✗ Branch 25 → 26 not taken.
✗ Branch 25 → 27 not taken.
✗ Branch 28 → 29 not taken.
✗ Branch 28 → 30 not taken.
4 this, [this, watcher, record, databasePath, sequence] {
1235
1/1
✓ Branch 2 → 3 taken 1 time.
1 const auto result = watcher->result();
1236
1/1
✓ Branch 3 → 4 taken 1 time.
1 watcher->deleteLater();
1237 3 if (sequence != m_reconciliationSequence ||
1238
1/2
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 11 not taken.
1 m_activeReconciliationToken != record.token ||
1239
2/4
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 11 not taken.
✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 11 not taken.
3 m_folder.accountId != record.mailId.accountId ||
1240
3/6
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 1 time.
✓ Branch 13 → 14 taken 1 time.
✗ Branch 13 → 15 not taken.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 1 time.
2 m_cache->databasePath() != databasePath)
1241 ✗ return;
1242
1/1
✓ Branch 18 → 19 taken 1 time.
1 const auto current = m_cache->journal().mutation(record.token);
1243
3/6
✓ Branch 20 → 21 taken 1 time.
✗ Branch 20 → 25 not taken.
✓ Branch 22 → 23 taken 1 time.
✗ Branch 22 → 25 not taken.
✗ Branch 27 → 28 not taken.
✓ Branch 27 → 30 taken 1 time.
2 if (!current || current->phase != MutationPhase::Ambiguous ||
1244
1/2
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 1 time.
1 current->kind != MutationKind::Append) {
1245 ✗ finishAmbiguousReconciliation();
1246 ✗ return;
1247 }
1248
1/2
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 46 taken 1 time.
1 if (!result.error.isEmpty()) {
1249 ✗ qCWarning(lcController) << "APPEND reconciliation read deferred:"
1250 ✗ << result.error;
1251 ✗ finishAmbiguousReconciliation();
1252 ✗ QTimer::singleShot(2000, this,
1253 ✗ &MailMutationPipeline::replayMutationJournal);
1254 ✗ return;
1255 }
1256
1/2
✓ Branch 46 → 47 taken 1 time.
✗ Branch 46 → 64 not taken.
1 if (result.valid) {
1257 // The durable journal owns verified bytes; a retry also
1258 // survives process restart. Dispatch rechecks the BLOB
1259 // before handing it to IMAP.
1260
1/1
✓ Branch 50 → 51 taken 1 time.
1 m_cache->journal().updateMutationPhase(record.token,
1261 MutationPhase::Queued);
1262
1/1
✓ Branch 53 → 54 taken 1 time.
1 MailJD::Performance::MutationPerformanceTracker::instance()
1263
1/1
✓ Branch 54 → 55 taken 1 time.
1 .reconciliationDeferred(record.token);
1264
1/1
✓ Branch 55 → 56 taken 1 time.
1 finishAmbiguousReconciliation();
1265
2/3
✓ Branch 57 → 58 taken 1 time.
✓ Branch 59 → 60 taken 1 time.
✗ Branch 59 → 62 not taken.
1 if (const auto queued = m_cache->journal().mutation(record.token))
1266
1/1
✓ Branch 61 → 62 taken 1 time.
1 dispatchMutation(*queued);
1267 } else {
1268 ✗ m_cache->journal().updateMutationPhase(
1269 ✗ record.token, MutationPhase::Ambiguous,
1270 ✗ tr("No matching server copy was found and message "
1271 "content is not persisted; manual review required"));
1272 ✗ m_deps.statusMessage(
1273 ✗ tr("Message copy needs review; it was not repeated"));
1274 ✗ MailJD::Performance::MutationPerformanceTracker::instance()
1275 ✗ .reconciliationFinished(record.token, false);
1276 ✗ finishAmbiguousReconciliation();
1277 }
1278
2/4
✓ Branch 78 → 79 taken 1 time.
✗ Branch 78 → 80 not taken.
✓ Branch 82 → 83 taken 1 time.
✗ Branch 82 → 85 not taken.
1 });
1279
1/1
✓ Branch 15 → 16 taken 2 times.
2 watcher->setFuture(QtConcurrent::run(&m_appendPayloadPool,
1280
0/4
✗ Branch 37 → 38 not taken.
✗ Branch 37 → 39 not taken.
✗ Branch 40 → 41 not taken.
✗ Branch 40 → 42 not taken.
4 [databasePath, account = record.mailId.accountId,
1281
1/1
✓ Branch 14 → 15 taken 2 times.
2 token = record.token]() -> AppendPayloadCheckResult {
1282
1/1
✓ Branch 2 → 3 taken 2 times.
2 MailCache reader;
1283
2/3
✓ Branch 3 → 4 taken 2 times.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 7 taken 2 times.
2 if (!reader.openReadOnly(databasePath, 2000))
1284 ✗ return {false, reader.lastError()};
1285 2 QString readError;
1286
1/1
✓ Branch 8 → 9 taken 2 times.
4 const bool valid = reader.journal().appendPayload(token, account,
1287 2 &readError).has_value();
1288 2 return {valid, readError};
1289
0/1
✗ Branch 5 → 6 not taken.
4 }));
1290 2 }
1291
1292 // reconcileAmbiguousMutation step: a MOVE not found in the target is
1293 // replayed only when the source still holds the exact UID.
1294 2 void MailMutationPipeline::searchReconciliationSource(const MutationRecord &record) {
1295 auto sourceSelected = connect(
1296 2 m_bodyImap, &ImapService::folderSelected, this,
1297 4 [this, record](const QString &sourcePath, int, quint32,
1298 quint64) {
1299
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 2 times.
2 if (sourcePath != record.folderPath)
1300 ✗ return;
1301
1/2
✓ Branch 6 → 7 taken 2 times.
✗ Branch 6 → 11 not taken.
2 if (!m_reconciliationConnections.isEmpty())
1302
1/1
✓ Branch 8 → 9 taken 2 times.
2 QObject::disconnect(
1303
1/1
✓ Branch 7 → 8 taken 2 times.
4 m_reconciliationConnections.takeFirst());
1304
1/1
✓ Branch 13 → 14 taken 2 times.
2 m_reconciliationConnections.append(connect(
1305
1/1
✓ Branch 12 → 13 taken 2 times.
2 m_bodyImap, &ImapService::searchResultReceived, this,
1306 4 [this, record](const QList<qint64> &sourceUids) {
1307
1/2
✓ Branch 3 → 4 taken 2 times.
✗ Branch 3 → 8 not taken.
2 if (!m_reconciliationConnections.isEmpty())
1308
1/1
✓ Branch 5 → 6 taken 2 times.
2 QObject::disconnect(
1309
1/1
✓ Branch 4 → 5 taken 2 times.
4 m_reconciliationConnections.takeFirst());
1310
2/2
✓ Branch 9 → 10 taken 1 time.
✓ Branch 9 → 27 taken 1 time.
2 if (sourceUids.contains(record.mailId.uid)) {
1311 // Source evidence proves the previous dispatch did
1312 // not commit; replaying this exact UID is now safe.
1313 2 m_cache->journal().updateMutationPhase(
1314
1/1
✓ Branch 13 → 14 taken 1 time.
1 record.token, MutationPhase::Queued);
1315 1 MailJD::Performance::MutationPerformanceTracker::instance()
1316 1 .reconciliationDeferred(record.token);
1317 1 finishAmbiguousReconciliation();
1318
1/2
✓ Branch 22 → 23 taken 1 time.
✗ Branch 22 → 25 not taken.
1 if (const auto queued =
1319
1/1
✓ Branch 20 → 21 taken 1 time.
1 m_cache->journal().mutation(record.token))
1320
1/1
✓ Branch 24 → 25 taken 1 time.
1 dispatchMutation(*queued);
1321 } else {
1322 1 m_cache->journal().updateMutationPhase(
1323
1/1
✓ Branch 30 → 31 taken 1 time.
1 record.token, MutationPhase::Ambiguous,
1324
1/1
✓ Branch 29 → 30 taken 1 time.
2 tr("Message was found in neither mailbox; "
1325 "manual review required"));
1326
1/1
✓ Branch 34 → 35 taken 1 time.
1 m_deps.statusMessage(
1327
1/1
✓ Branch 33 → 34 taken 1 time.
2 tr("Move needs review; it was not repeated"));
1328 1 MailJD::Performance::MutationPerformanceTracker::instance()
1329 1 .reconciliationFinished(record.token, false);
1330 1 finishAmbiguousReconciliation();
1331 }
1332 2 }));
1333 2 m_bodyImap->searchByMessageId(record.messageId);
1334
1/1
✓ Branch 3 → 4 taken 2 times.
2 });
1335
1/1
✓ Branch 5 → 6 taken 2 times.
2 m_reconciliationConnections.append(sourceSelected);
1336
1/1
✓ Branch 6 → 7 taken 2 times.
2 m_bodyImap->selectFolder(record.folderPath);
1337 2 }
1338
1339 14 void MailMutationPipeline::reconcileAmbiguousMutation(
1340 const MutationRecord &record) {
1341
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 13 times.
20 if (record.mailId.accountId != m_folder.accountId) return;
1342
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 19 taken 12 times.
13 if (record.messageId.isEmpty()) {
1343 1 m_cache->journal().updateMutationPhase(
1344
1/1
✓ Branch 10 → 11 taken 1 time.
1 record.token, MutationPhase::Ambiguous,
1345
1/1
✓ Branch 9 → 10 taken 1 time.
2 tr("No Message-ID is available for safe reconciliation"));
1346
2/2
✓ Branch 13 → 14 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
1 m_deps.actionError(tr("Move needs review; it was not repeated"));
1347
1/1
✓ Branch 16 → 17 taken 1 time.
1 MailJD::Performance::MutationPerformanceTracker::instance()
1348
1/1
✓ Branch 17 → 18 taken 1 time.
1 .reconciliationFinished(record.token, false);
1349 1 return;
1350 }
1351
1352
5/6
✓ Branch 19 → 20 taken 5 times.
✓ Branch 19 → 23 taken 7 times.
✓ Branch 21 → 22 taken 5 times.
✗ Branch 21 → 23 not taken.
✓ Branch 24 → 25 taken 5 times.
✓ Branch 24 → 29 taken 7 times.
12 if (!m_bodyImap && m_imapConfig.host.isEmpty()) {
1353
2/2
✓ Branch 26 → 27 taken 4 times.
✓ Branch 26 → 28 taken 1 time.
5 if (!m_ambiguousReconciliationQueue.contains(record.token))
1354
1/1
✓ Branch 27 → 28 taken 4 times.
4 m_ambiguousReconciliationQueue.enqueue(record.token);
1355 5 return;
1356 }
1357
1/1
✓ Branch 29 → 30 taken 7 times.
7 m_deps.ensureBodyConnection();
1358
3/6
✓ Branch 30 → 31 taken 7 times.
✗ Branch 30 → 35 not taken.
✗ Branch 32 → 33 not taken.
✓ Branch 32 → 36 taken 7 times.
✗ Branch 37 → 38 not taken.
✓ Branch 37 → 42 taken 7 times.
14 if (!m_bodyImap ||
1359
0/2
✗ Branch 34 → 35 not taken.
✗ Branch 34 → 36 not taken.
7 (m_bodyImap->state() != ImapService::State::Authenticated &&
1360 ✗ m_bodyImap->state() != ImapService::State::Selected)) {
1361 ✗ if (!m_ambiguousReconciliationQueue.contains(record.token))
1362 ✗ m_ambiguousReconciliationQueue.enqueue(record.token);
1363 ✗ return;
1364 }
1365
1366 7 m_activeReconciliationToken = record.token;
1367 7 const quint64 reconciliationSequence = ++m_reconciliationSequence;
1368
1/1
✓ Branch 42 → 43 taken 7 times.
7 QTimer::singleShot(30000, this, [this, token = record.token,
1369 reconciliationSequence]() {
1370 ✗ if (m_activeReconciliationToken != token ||
1371 ✗ m_reconciliationSequence != reconciliationSequence)
1372 ✗ return;
1373 ✗ m_cache->journal().updateMutationPhase(
1374 token, MutationPhase::Ambiguous,
1375 ✗ tr("Server verification timed out; manual review required"));
1376 ✗ const auto record = m_cache->journal().mutation(token);
1377 ✗ m_deps.actionError(record && record->kind == MutationKind::Append
1378 ✗ ? tr("Message copy verification timed out; it was not repeated")
1379 : tr("Move verification timed out; it was not repeated"));
1380 ✗ MailJD::Performance::MutationPerformanceTracker::instance()
1381 ✗ .reconciliationFinished(token, false);
1382 ✗ finishAmbiguousReconciliation();
1383 ✗ });
1384 auto targetSelected = connect(
1385 7 m_bodyImap, &ImapService::folderSelected, this,
1386 14 [this, record](const QString &path, int, quint32, quint64) {
1387
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 6 times.
6 if (path != record.targetFolder)
1388 ✗ return;
1389
1/2
✓ Branch 6 → 7 taken 6 times.
✗ Branch 6 → 11 not taken.
6 if (!m_reconciliationConnections.isEmpty()) {
1390
2/2
✓ Branch 7 → 8 taken 6 times.
✓ Branch 8 → 9 taken 6 times.
6 QObject::disconnect(m_reconciliationConnections.takeFirst());
1391 }
1392
1/1
✓ Branch 13 → 14 taken 6 times.
6 m_reconciliationConnections.append(connect(
1393
1/1
✓ Branch 12 → 13 taken 6 times.
6 m_bodyImap, &ImapService::searchResultReceived, this,
1394 12 [this, record](const QList<qint64> &targetUids) {
1395
1/2
✓ Branch 3 → 4 taken 6 times.
✗ Branch 3 → 8 not taken.
6 if (!m_reconciliationConnections.isEmpty())
1396
1/1
✓ Branch 5 → 6 taken 6 times.
6 QObject::disconnect(
1397
1/1
✓ Branch 4 → 5 taken 6 times.
12 m_reconciliationConnections.takeFirst());
1398
2/2
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 12 taken 4 times.
6 if (!targetUids.isEmpty()) {
1399 2 confirmReconciliationInTarget(record, targetUids);
1400 2 return;
1401 }
1402
1403
2/2
✓ Branch 12 → 13 taken 2 times.
✓ Branch 12 → 15 taken 2 times.
4 if (record.kind == MutationKind::Append) {
1404 2 checkAppendPayloadForReconciliation(record);
1405 2 return;
1406 }
1407
1408 2 searchReconciliationSource(record);
1409 }));
1410 6 m_bodyImap->searchByMessageId(record.messageId);
1411
1/1
✓ Branch 44 → 45 taken 7 times.
7 });
1412
1/1
✓ Branch 46 → 47 taken 7 times.
7 m_reconciliationConnections.append(targetSelected);
1413
1/1
✓ Branch 47 → 48 taken 7 times.
7 m_bodyImap->selectFolder(record.targetFolder);
1414 7 }
1415
1416 8 void MailMutationPipeline::finishAmbiguousReconciliation() {
1417 8 ++m_reconciliationSequence;
1418
2/2
✓ Branch 9 → 5 taken 1 time.
✓ Branch 9 → 10 taken 8 times.
9 for (const auto &connection : std::as_const(m_reconciliationConnections))
1419
1/1
✓ Branch 6 → 7 taken 1 time.
1 QObject::disconnect(connection);
1420 8 m_reconciliationConnections.clear();
1421 8 m_activeReconciliationToken = 0;
1422 8 QTimer::singleShot(0, this,
1423
1/1
✓ Branch 11 → 12 taken 8 times.
8 &MailMutationPipeline::reconcileNextAmbiguousMutation);
1424 8 }
1425