aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md11
-rwxr-xr-xassets/mailsync.sh9
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md68
-rw-r--r--src/mainwindow.cpp93
-rw-r--r--src/mainwindow.h25
5 files changed, 197 insertions, 9 deletions
diff --git a/README.md b/README.md
index bf052b3..5b9a3e6 100644
--- a/README.md
+++ b/README.md
@@ -306,6 +306,17 @@ Two things any replacement has to get right, both learned the hard way:
it reports success, clears the unsynced-changes count, and will quit on it
during a sync-on-exit. The previous version ended in an unconditional
`exit 0`, so a failed `mbsync` was indistinguishable from a clean run.
+- **Exit 75 when another run holds the lock**, rather than 1. A skip is not a
+ failure: the other run is doing the work, and with a timer every ten minutes
+ a click landing inside one is routine. qtmaildir reports 75 as "a sync is
+ already running" and leaves the log pane alone, where any other non-zero code
+ raises an error.
+
+While a sync this window started is running, the status bar shows an
+indeterminate progress bar. It is deliberately not a percentage: `mbsync`
+reports no progress, so a bar filling left to right would be inventing one. A
+sync started outside the application, by your cron timer, is not currently
+visible here at all.
## Unsynced changes
diff --git a/assets/mailsync.sh b/assets/mailsync.sh
index d5f51ee..2134439 100755
--- a/assets/mailsync.sh
+++ b/assets/mailsync.sh
@@ -51,9 +51,12 @@ if ! flock -n 200; then
# Both streams again: a caller that skipped because the cron run holds
# the lock needs to be told, not left with silence and an error code.
msg="$(date -Iseconds) === SKIPPED: previous run still in progress ==="
- echo "$msg" >> "$LOGFILE"
- echo "$msg" >&2
- exit 1
+ echo "$msg" | tee -a "$LOGFILE" >&2
+ # 75 (EX_TEMPFAIL), not 1. A skip is not a failure: the other run is
+ # doing the work. qtmaildir reports 1 as "sync failed" and shows its log
+ # pane, which is wrong for a click that landed during the cron run, and
+ # cron fires every ten minutes so that overlap is routine.
+ exit 75
fi
# Statuses are written to files rather than shell variables because the
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index 4df48dd..5fcd6d8 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -65,6 +65,7 @@ taking that too literally.
| 24 | No right-click actions on the thread list | discoverability | S | open |
| 25 | No select-all, and bulk actions are undiscoverable | workflow | S | open |
| 26 | No way to add or remove an arbitrary tag from the UI | workflow | S | **done** |
+| 27 | The UI cannot see a sync it did not start | feedback | S | open |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -1192,6 +1193,73 @@ character. The test was corrected, not the validator.
Rendered and inspected rather than only asserted.
+## 27. The UI cannot see a sync it did not start
+
+**Observed (user, 2026-08-04), as a question:** "will the status bar be aware of
+cronjob fired syncs?" It is not. The progress bar and the busy state are driven
+by `MailSync`'s own `QProcess`, so they know only about syncs this window
+started. The user's cron timer fires every ten minutes and the application is
+blind to it.
+
+**The lock file is already the signal; no new file is needed.** `mailsync.sh`
+does `exec 200>"$LOCKFILE"` and then `flock -n 200` on `/tmp/mbsync.lock`.
+Another process can test whether that lock is held, without disturbing it, by
+opening the same path on its own descriptor and attempting `flock(LOCK_EX |
+LOCK_NB)`: the attempt fails exactly when someone holds it, and succeeds
+otherwise, in which case the tester drops it again immediately.
+
+This is better than the status file first proposed. A kernel lock **cannot go
+stale**: it is released when the holding process dies, however it dies. A status
+file written by the script survives a `kill -9` and would leave the UI claiming
+a sync is running forever, which then needs a heartbeat and a staleness
+timeout, none of which the lock needs.
+
+**Decided (user, 2026-08-04): poll continuously, not only while quitting.** A
+narrower version that polled only during the exit prompt was offered and
+declined: the user wants the status bar informed at all times, not only at the
+one moment it changes a decision.
+
+**Approach.**
+
+- A `syncLockHeld()` helper: `open()` the lock path, `flock(LOCK_EX|LOCK_NB)`,
+ close. Held when the attempt fails with `EWOULDBLOCK`.
+- A `QTimer` polling it. **One second is finer than this needs**; a sync runs
+ for tens of seconds, so two seconds is plenty and halves a wakeup that never
+ stops.
+- When the lock is held and `MailSync` is NOT the holder, show the busy state
+ with wording that says so: "Syncing (started elsewhere)". The existing
+ `setSyncBusy()` covers the widgets; this adds a third state between "idle"
+ and "we are syncing".
+- On the exit path, this replaces a hedge with a fact. Today, quitting while
+ cron syncs says the window "cannot see it finish". With the lock watched it
+ can wait for the lock to clear and then quit, which is what the user actually
+ wants to happen.
+
+**Verify before building, both cheap and both the kind of assumption this
+project has been bitten by twice:**
+
+- **That the lock is observable at all.** `flock` semantics across processes,
+ where the holder opened the file with `exec 200>`, are an assumption about
+ Linux behaviour, not a certainty. A twenty-line probe settles it. Do not
+ design on top of it unproven.
+- **That polling it cannot disturb notmuch.** `mailsync.sh` runs `notmuch new`,
+ and notmuch's write lock is process-exclusive. Touching `/tmp/mbsync.lock`
+ should be unrelated, but the interaction is worth one look rather than an
+ assumption.
+
+**Constraints.**
+
+- The lock path becomes a contract between the script and the application,
+ where today the application knows nothing about it. It has to be documented
+ on both sides, and the script cannot move or rename it casually afterwards.
+- The poll must not run a query or touch the database. It reports what another
+ process is doing; the existing `runCurrentQuery()` on a completed sync of our
+ own already handles refreshing, and a cron sync that finishes will be picked
+ up the next time the user runs a query.
+- Do not report an externally-started sync as one this window can cancel. The
+ Sync button should be disabled while the lock is held, since starting one
+ would only produce the `EX_TEMPFAIL` skip.
+
---
## Deferred, unsized, or split out
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 000be85..0a9f9d8 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -36,6 +36,7 @@
#include <QMenuBar>
#include <QMessageBox>
#include <QPlainTextEdit>
+#include <QProgressBar>
#include <QPushButton>
#include <QSettings>
#include <QSplitter>
@@ -193,6 +194,8 @@ void MainWindow::closeEvent(QCloseEvent *event)
if (box.clickedButton() == sync) {
if (m_sync->start()) {
m_syncingForExit = true;
+ m_syncLog->clear();
+ setSyncBusy(true);
m_statusLabel->setText(tr("Syncing before quitting..."));
event->ignore();
return;
@@ -212,6 +215,8 @@ void MainWindow::closeEvent(QCloseEvent *event)
} else if (m_config.syncOnExit() == Config::SyncOnExit::Always) {
if (m_sync->start()) {
m_syncingForExit = true;
+ m_syncLog->clear();
+ setSyncBusy(true);
m_statusLabel->setText(tr("Syncing before quitting..."));
event->ignore();
return;
@@ -330,6 +335,18 @@ void MainWindow::buildUi()
m_pendingLabel->hide();
statusBar()->addPermanentWidget(m_pendingLabel);
+ // Indeterminate: setRange(0, 0). A sync has no measurable progress, since
+ // mbsync reports no percentage and the script's output is unstructured, so
+ // a bar filling left to right would be inventing a fraction. This one
+ // animates to say "working, duration unknown".
+ m_syncProgress = new QProgressBar(this);
+ m_syncProgress->setObjectName(QStringLiteral("syncProgress"));
+ m_syncProgress->setRange(0, 0);
+ m_syncProgress->setTextVisible(false);
+ m_syncProgress->setMaximumWidth(120);
+ m_syncProgress->hide();
+ statusBar()->addPermanentWidget(m_syncProgress);
+
// Query row.
auto *queryRow = new QHBoxLayout;
m_accountBox = new QComboBox(central);
@@ -361,10 +378,35 @@ void MainWindow::buildUi()
connect(m_markReadTimer, &QTimer::timeout,
this, &MainWindow::markCurrentThreadRead);
- m_syncLog = new QPlainTextEdit(central);
+ // The pane and its close button travel together: a QPlainTextEdit has
+ // nowhere to put one, and a pane that appears on a failed sync and can
+ // never be dismissed is worse than one that does not appear at all.
+ m_syncLogPane = new QWidget(central);
+ m_syncLogPane->setObjectName(QStringLiteral("syncLogPane"));
+ auto *syncLogLayout = new QVBoxLayout(m_syncLogPane);
+ syncLogLayout->setContentsMargins(0, 0, 0, 0);
+ syncLogLayout->setSpacing(2);
+
+ auto *syncLogHeader = new QHBoxLayout;
+ syncLogHeader->addWidget(new QLabel(tr("Sync output"), m_syncLogPane));
+ syncLogHeader->addStretch();
+
+ auto *closeSyncLog = new QPushButton(tr("Close"), m_syncLogPane);
+ closeSyncLog->setObjectName(QStringLiteral("closeSyncLog"));
+ closeSyncLog->setToolTip(tr("Hide the sync output until the next failure"));
+ connect(closeSyncLog, &QPushButton::clicked,
+ m_syncLogPane, &QWidget::hide);
+ syncLogHeader->addWidget(closeSyncLog);
+ syncLogLayout->addLayout(syncLogHeader);
+
+ m_syncLog = new QPlainTextEdit(m_syncLogPane);
m_syncLog->setReadOnly(true);
- m_syncLog->setMaximumHeight(120);
- m_syncLog->hide();
+ // 200 rather than 120: mbsync's output is wide and repetitive, and the
+ // shorter pane showed too little of it to read.
+ m_syncLog->setMaximumHeight(200);
+ syncLogLayout->addWidget(m_syncLog);
+
+ m_syncLogPane->hide();
m_syncButton = new QPushButton(tr("Sync"), central);
m_sync = new MailSync(m_config.syncCommand(), this);
@@ -374,8 +416,14 @@ void MainWindow::buildUi()
tr("No sync command configured ([sync] command in qtmaildir.conf)"));
}
connect(m_syncButton, &QPushButton::clicked, this, [this]() {
- if (!m_sync->start())
+ if (!m_sync->start()) {
m_statusLabel->setText(tr("Sync already running"));
+ return;
+ }
+ // Fresh run, fresh output: leaving the previous run's lines in place
+ // makes a stale failure look like the current one.
+ m_syncLog->clear();
+ setSyncBusy(true);
});
connect(m_sync, &MailSync::finished, this, &MainWindow::onSyncFinished);
connect(m_sync, &MailSync::outputReceived, this, [this](const QString &chunk) {
@@ -451,7 +499,7 @@ void MainWindow::buildUi()
m_splitter->setStretchFactor(1, 2);
layout->addWidget(m_splitter, 1);
- layout->addWidget(m_syncLog);
+ layout->addWidget(m_syncLogPane);
setCentralWidget(central);
@@ -1013,6 +1061,8 @@ void MainWindow::onWorkerError(const QString &message)
void MainWindow::onSyncFinished(bool success, int exitCode)
{
+ setSyncBusy(false);
+
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
@@ -1033,9 +1083,28 @@ void MainWindow::onSyncFinished(bool success, int exitCode)
runCurrentQuery();
// A sync is the usual way new tags enter the database.
requestAllTags();
+ } else if (exitCode == kSyncSkippedExitCode) {
+ // Not a failure: another run holds the lock and is doing the work.
+ // The user's cron fires every ten minutes, so a click landing inside
+ // one is routine and must not raise an error or the log pane.
+ m_statusLabel->setText(tr("A sync is already running (started "
+ "elsewhere); this one was skipped"));
+
+ if (m_syncingForExit) {
+ // The other run is syncing, but this application cannot see when
+ // it finishes, so it cannot promise the changes are across. Leave
+ // the window open and say so rather than quitting on a guess.
+ m_syncingForExit = false;
+ QMessageBox::information(
+ this, tr("Sync already running"),
+ tr("Another sync was already in progress, so this one was "
+ "skipped. Your changes are most likely being carried over "
+ "by that run, but this window cannot see it finish, so it "
+ "has been left open."));
+ }
} else {
m_statusLabel->setText(tr("Sync failed (exit %1)").arg(exitCode));
- m_syncLog->show();
+ m_syncLogPane->show();
if (m_syncingForExit) {
// Do NOT quit: the edits are still unsynced and quitting now would
@@ -1073,6 +1142,18 @@ void MainWindow::onTagsApplied(const TagChange &change)
}
}
+void MainWindow::setSyncBusy(bool busy)
+{
+ m_syncProgress->setVisible(busy);
+ // Disabled rather than left clickable: MailSync::start() already refuses a
+ // second run, but a button that looks live and does nothing is worse than
+ // one that shows it is unavailable.
+ m_syncButton->setEnabled(!busy && m_sync && m_sync->isAvailable());
+
+ if (busy)
+ m_statusLabel->setText(tr("Syncing..."));
+}
+
void MainWindow::updatePendingIndicator()
{
if (m_pendingEdits <= 0) {
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 7897038..d1f7b1c 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -39,6 +39,7 @@ class QPushButton;
class QComboBox;
class QPlainTextEdit;
class QSplitter;
+class QProgressBar;
class QTimer;
class ThreadListModel;
@@ -66,6 +67,15 @@ public:
/// cid: references from resolving to another's.
static QString cidPrefixForIndex(int index);
+ /// What the sync command returns when another run already holds the lock.
+ ///
+ /// EX_TEMPFAIL from sysexits.h. A skip is not a failure: the other run is
+ /// doing the work, and with a cron timer every ten minutes a click landing
+ /// inside one is routine. Reporting it as an error would show a log pane
+ /// and an alarming status for a situation that needs neither.
+ /// `assets/mailsync.sh` is the reference implementation of this contract.
+ static constexpr int kSyncSkippedExitCode = 75;
+
/// Path of the machine-written UI state file. Deliberately not
/// Config::defaultPath(): the config is hand-edited and must never gain a
/// base64 geometry blob, nor be rewritten on exit (QSettings does not
@@ -134,6 +144,15 @@ private:
/// Redraws the unsynced-edits indicator from m_pendingEdits.
void updatePendingIndicator();
+ /// Shows or hides the "syncing" state: the progress bar and a disabled
+ /// Sync button.
+ ///
+ /// The bar is INDETERMINATE by design. mbsync reports no percentage and
+ /// the script's output is unstructured, so a bar that filled from left to
+ /// right would be inventing a fraction nobody knows. An indeterminate one
+ /// says "working, duration unknown", which is the truth.
+ void setSyncBusy(bool busy);
+
/// Opens the tag dialog on the current selection and applies its result.
///
/// The only route to an arbitrary tag: every other tag action writes a
@@ -185,6 +204,12 @@ private:
/// Says how many tag changes have not been seen to reach the mail store.
/// Hidden entirely at zero rather than reading "0 unsynced", which is noise.
QLabel *m_pendingLabel = nullptr;
+
+ /// Indeterminate, shown only while a sync runs. See setSyncBusy().
+ QProgressBar *m_syncProgress = nullptr;
+
+ /// Holds the sync log and its close button, so the pane can be dismissed.
+ QWidget *m_syncLogPane = nullptr;
QPlainTextEdit *m_syncLog = nullptr;
/// Action name (as used in [keys]) to the QAction implementing it. Owned