summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/mainwindow.cpp132
-rw-r--r--src/mainwindow.h38
-rw-r--r--src/notmuchworker.cpp11
-rw-r--r--src/notmuchworker.h1
4 files changed, 179 insertions, 3 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 7cd2769..7c7b801 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1275,9 +1275,15 @@ void MainWindow::revertPendingTagChange()
// The undo entry describes a change that never landed, so it would apply a
// spurious inverse if the user pressed undo.
+ //
+ // undo() alone, deliberately. This used to clear() the whole stack
+ // afterwards, which threw away every earlier step the user had built up
+ // because one later write was rejected: undoing an archive of fifty
+ // threads became impossible if the flag after it happened to land during a
+ // sync. undo() has already taken the failed command off the redo side of
+ // the stack, and the commands under it describe changes that did land.
if (m_undoStack.canUndo())
m_undoStack.undo();
- m_undoStack.clear();
m_pendingChange = {};
m_pendingThreadIds.clear();
@@ -1287,14 +1293,78 @@ void MainWindow::onWorkerError(const QString &message)
{
// Spec: the UI updates optimistically and reverts if the write fails.
// Without this the list would keep showing a tag the database never got.
+ //
+ // A running sync does NOT arrive here. The read-write open blocks on the
+ // lock and then succeeds rather than failing (measured; see the comment at
+ // the open in notmuchworker.cpp), so anything reaching this point is a real
+ // failure that waiting cannot fix. The stall a running sync does cause is
+ // avoided by not sending the write at all, in sendThreadTagChange().
revertPendingTagChange();
+ updatePendingIndicator();
m_statusLabel->setText(message);
}
+bool MainWindow::aSyncHoldsTheWriteLock() const
+{
+ // Both sources, exactly as updateSyncControls() reads them. A local sync
+ // holds the same exclusive lock a cron one does, so an edit made during it
+ // would block on precisely the same open.
+ return m_localSyncBusy || m_externalSyncBusy;
+}
+
+void MainWindow::flushHeldEdits()
+{
+ if (m_heldEdits.isEmpty())
+ return;
+
+ // Taken by value and cleared first: sendThreadTagChange() writes
+ // m_pendingThreadIds, and re-entering partway through the queue must not
+ // find the same edits still waiting.
+ const QVector<HeldEdit> edits = m_heldEdits;
+ m_heldEdits.clear();
+
+ for (const HeldEdit &edit : edits) {
+ // Take the optimistic update back before sending, because
+ // sendThreadTagChange() applies it again. applyTagChange() is
+ // idempotent per tag so the rows do not visibly flicker; without this
+ // the change is applied twice and a later revert undoes only one of
+ // them, leaving a row showing a tag the database never got.
+ for (const QString &threadId : edit.threadIds) {
+ m_model->applyTagChange(threadId, edit.change.removed,
+ edit.change.added);
+ }
+
+ sendThreadTagChange(edit.threadIds, edit.change.added,
+ edit.change.removed, edit.change.description);
+ }
+
+ // Held edits stop counting as held; what counts now is whatever
+ // onTagsApplied() confirms.
+ updatePendingIndicator();
+
+ showTransientStatus(
+ tr("%n held change(s) sent now that the sync has finished", "",
+ int(edits.size())));
+}
+
void MainWindow::onSyncFinished(bool success, int exitCode)
{
setSyncBusy(false);
+ // The local sync no longer holds the write lock, whatever its outcome, so
+ // edits held during it can go now.
+ //
+ // The count below is safe: applyTagsToThreads is a QUEUED call, so the
+ // onTagsApplied() that records these edits arrives after this function has
+ // returned, and therefore after the success branch has cleared the map.
+ // They are counted, not wiped.
+ //
+ // These edits reach the index after the sync that would have carried them,
+ // so they go to the mail store on the NEXT run. That is the same one-run
+ // delay any edit made mid-sync gets, bounded by the cron interval.
+ const bool sentHeldEdits = !m_heldEdits.isEmpty();
+ flushHeldEdits();
+
if (success) {
// Only a SUCCESSFUL sync clears the count. Clearing on failure would
// assert the edits had reached the mail store when the sync is exactly
@@ -1306,6 +1376,21 @@ void MainWindow::onSyncFinished(bool success, int exitCode)
showTransientStatus(tr("Sync complete"));
if (m_syncingForExit) {
+ // Edits held during THIS sync were only just sent, on a queued
+ // connection, so they have not reached the index yet and this sync
+ // certainly did not carry them. Quitting here would discard exactly
+ // the work the prompt exists to protect. Tell the user and stay
+ // open; the indicator shows what is still outstanding.
+ if (sentHeldEdits) {
+ m_syncingForExit = false;
+ QMessageBox::information(
+ this, tr("Changes still to sync"),
+ tr("Changes you made while the sync was running have only "
+ "now been applied, so that sync did not carry them. "
+ "Sync once more before quitting."));
+ return;
+ }
+
// The work is safely across, so finish the quit the user asked for.
m_syncingForExit = false;
m_closeApproved = true;
@@ -1430,6 +1515,11 @@ void MainWindow::onExternalSyncStateChanged(SyncMonitor::State state)
m_localSyncHoldsLock = false;
m_externalSyncBusy = false;
updateSyncControls();
+
+ // A local sync releases the write lock exactly as a background one
+ // does, and an edit made during it is held the same way. Without this
+ // the held edits would wait for the NEXT sync to come and go.
+ flushHeldEdits();
return;
}
@@ -1453,6 +1543,14 @@ void MainWindow::onExternalSyncStateChanged(SyncMonitor::State state)
tr("Background sync completed. Press Enter in the query bar to "
"refresh."));
}
+
+ // OUTSIDE the Idle branch, deliberately. Unknown clears the busy flag above,
+ // so writes resume from here on; leaving the flush inside Idle would let a
+ // new edit go straight out while the ones already held sat waiting for an
+ // Idle that a broken /proc/locks will never report. After the status
+ // message, which flushHeldEdits() overwrites with its own when it sent
+ // something.
+ flushHeldEdits();
}
void MainWindow::showTransientStatus(const QString &text)
@@ -1513,7 +1611,13 @@ void MainWindow::recordPendingEdit(const QString &messageId, const QString &tag,
int MainWindow::pendingEditCount() const
{
- return m_pendingTagEdits.size() + m_unnettablePendingEdits;
+ // A held edit has NOT reached the index, so onTagsApplied() never counted
+ // it. It still has to count here: this is what the exit prompt reads, and
+ // an edit waiting on a lock is precisely the work quitting would lose.
+ // Each held edit counts as one whatever its size, since it carries thread
+ // ids rather than message ids and cannot be netted against the map.
+ const int held = int(m_heldEdits.size());
+ return m_pendingTagEdits.size() + m_unnettablePendingEdits + held;
}
void MainWindow::updatePendingIndicator()
@@ -1679,6 +1783,30 @@ void MainWindow::sendThreadTagChange(const QStringList &threadIds,
m_messageView->setTags(m_model->threadAt(current.row()).tags);
}
+ // A sync holds notmuch's exclusive write lock, and the worker's read-write
+ // open BLOCKS on it rather than failing: measured 9.158s against a 12s
+ // hold, returning SUCCESS. Sending now would freeze the worker thread for
+ // the rest of the sync, queueing every later query and thread load behind
+ // it. Hold the edit and send it when the lock frees.
+ //
+ // The rows keep the optimistic update applied above, which is honest: it is
+ // what the user asked for and it is going to be applied.
+ if (aSyncHoldsTheWriteLock()) {
+ m_heldEdits.append(HeldEdit{
+ threadIds, TagChange{ {}, add, remove, description } });
+
+ // NOT transient. This describes state that lasts until the sync ends,
+ // and a message that expired would leave the user with rows showing a
+ // tag the database has not got and no explanation of why.
+ m_statusLabel->setText(
+ tr("A sync is running; your change will be applied when it "
+ "finishes."));
+
+ // A held edit is outstanding work, so the indicator has to show it.
+ updatePendingIndicator();
+ return;
+ }
+
m_pendingThreadIds = threadIds;
m_pendingChange = TagChange{ {}, add, remove, description };
diff --git a/src/mainwindow.h b/src/mainwindow.h
index cf9aca6..22c8187 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -68,6 +68,15 @@ public:
/// load is discarded rather than painted, so no thread can reappear.
QString currentThreadId() const { return m_currentThreadId; }
+ /// True while an edit is held back because a sync holds the write lock.
+ /// Exposed for tests: the deferral is otherwise only observable by watching
+ /// the worker, which test_mainwindow has no database to drive.
+ bool hasEditAwaitingSend() const { return !m_heldEdits.isEmpty(); }
+
+ /// Whether the undo stack still holds anything. Exposed so a test can show
+ /// that a rejected write did not take unrelated history down with it.
+ bool canUndo() const { return m_undoStack.canUndo(); }
+
/// The cid: namespace prefix for the nth message of a thread.
///
/// MainWindow is the only producer of this value in the application. It
@@ -231,6 +240,35 @@ private:
/// Undoes the optimistic model update for a write the worker rejected.
void revertPendingTagChange();
+ /// Whether a write sent now would block the worker on notmuch's write lock.
+ ///
+ /// True only for a sync KNOWN to be running. `SyncMonitor::State::Unknown`
+ /// deliberately does not count: it means `/proc/locks` could not be read,
+ /// and holding every edit on a platform that cannot observe the lock at all
+ /// would strand them permanently.
+ bool aSyncHoldsTheWriteLock() const;
+
+ /// Sends every edit held while the lock was busy, oldest first.
+ void flushHeldEdits();
+
+ /// A tag change not yet sent to the worker, because a sync held the write
+ /// lock when the user made it.
+ ///
+ /// Held rather than sent because the read-write open BLOCKS: measured
+ /// 9.158s against a 12s lock hold, returning SUCCESS, not an error. Sending
+ /// into that freezes the worker thread, so every later query and thread
+ /// load queues behind it. The rows show the change meanwhile, which is
+ /// honest: it is what the user asked for and it is going to be applied.
+ struct HeldEdit {
+ QStringList threadIds;
+ TagChange change;
+ };
+
+ /// FIFO, because a sync lasts ~35s and the user can keep tagging through
+ /// it. Order matters: two edits touching one thread must reach the database
+ /// in the order they were made, or the later one does not win.
+ QVector<HeldEdit> m_heldEdits;
+
friend class ThreadTagCommand;
Config m_config;
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index a5096d2..3525a2b 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -296,8 +296,17 @@ void NotmuchWorker::applyTags(const TagChange &change)
&error);
if (status != NOTMUCH_STATUS_SUCCESS) {
+ // NOT reached by lock contention, despite the wording. Measured
+ // 2026-08-04: this call BLOCKS on a held write lock and then returns
+ // SUCCESS (9.158s against a 12s hold), so a running sync never lands
+ // here. What does land here is a genuinely broken open: bad
+ // permissions, a corrupt index, a missing database. None of those are
+ // helped by waiting, so the UI reverts rather than retrying.
+ //
+ // The stall a running sync DOES cause is avoided upstream, in
+ // MainWindow, by not sending the write at all while the lock is held.
emit errorOccurred(
- QStringLiteral("Cannot open database for writing (is a sync running?): %1")
+ QStringLiteral("Cannot open database for writing: %1")
.arg(QString::fromUtf8(error ? error
: notmuch_status_to_string(status))));
free(error);
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index 88187ec..96e75f8 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -80,6 +80,7 @@ signals:
void threadLoaded(const QVector<MessageRef> &messages, quint64 generation);
void tagsApplied(const TagChange &change);
void allTagsReady(const QStringList &tags, quint64 generation);
+
void errorOccurred(const QString &message);
private: