diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-29 11:25:02 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-29 11:25:02 +0200 |
| commit | 3fd999907ae8344f76ee4e5be1ac278a26f452ca (patch) | |
| tree | 17c01d44daee0147ecaca6cccd393471e6379cf1 /src/mailsync.h | |
| parent | 8c78dd139a77e896c72b7fc8b799af3c0df34344 (diff) | |
| download | qtmaildir-3fd999907ae8344f76ee4e5be1ac278a26f452ca.tar.gz qtmaildir-3fd999907ae8344f76ee4e5be1ac278a26f452ca.zip | |
feat: have the sync script report what it did
Item 174, and half of item 125.
The premise was corrected before any code. The note asks for an external
`notmuch new` to clear the pending count; it must not. That count means
tag mutations not yet known to have reached the MAIL STORE, which is the
server: an edit is in notmuch the moment it is made, and what is
outstanding is mbsync pushing the renamed Maildir files. `notmuch new`
re-indexes local files and pushes nothing, so clearing on it would tell
the user their work was safe to quit on while it was still local. The
entry's own proposal to watch notmuch_database_get_revision() was
rejected for the same reason: a revision moves when mail ARRIVES too, and
in neither case does it say anything about the server.
What was actually wrong was the reporting channel. The application
inferred a finished run from an inode in /proc/locks and from grepping
the log for its RUN END banner, which made a human-readable line into
wire format and could not say WHICH channels a run carried. The local
sync path has always narrowed its clear to the accounts it carried; the
external path could not, and cleared everything, so an edit to an
account a run never touched was reported as delivered.
So the script reports instead of leaving evidence to be inferred. It
writes ~/.local/state/qtmaildir/syncstatus.json atomically at the end of
every run, including a skip, naming the channels, both exit statuses and
a state of ok, failed or skipped. MailSync::readStatus() reads it,
MainWindow prefers it over the log banner and narrows the clear through
Account::syncChannel(). A skipped run clears nothing, which is item 125's
first half: the application can now see that a run happened and carried
nothing. The log banner and lastRunOutcome() stay as the fallback for a
missing file, which is what a first run after upgrading looks like.
This is the user's own framing of the scope: the script was written for
another system and adapted, and is now qtmaildir's only consumer, so it
serves the application rather than the reverse. Two facts made it safe to
act on: their crontab runs mailsync.sh and nothing else touches mail, and
~/bin/mailsync.sh is a symlink into this repo, so an edit is live on the
next tick.
Two bugs found while wiring it in, both recorded in the closed item.
A test read the developer's real sync state, twice: a [sync] section
naming only `log` leaves syncStatus() defaulting to the real file, so two
tests asserting that a FAILED run leaves the count alone read the last
real cron run, found ok, and cleared. Pinning only `status` has the
mirror problem. noSyncTestReadsTheRealSyncState() is the guard, modelled
on noTestCanSeeTheRealLockTable().
And Qt::ISODate carries no milliseconds. The status file is preferred
only when it describes THIS run, compared against when the lock appeared,
so a stale success cannot outrank a fresh failure; but the script writes
date -Iseconds, and a round trip of "now" comes back 329 ms behind,
measured. A fast sync's own file therefore parsed as stale and fell back
to the log, with nothing failing to say so. One second of slack matches
the precision the format carries.
Design: docs/superpowers/specs/2026-08-29-sync-status-file-design.md
Suite: 43 of 44, with undoMovesTheMessageBack failing as it does on
master (item 136).
Diffstat (limited to 'src/mailsync.h')
| -rw-r--r-- | src/mailsync.h | 80 |
1 files changed, 80 insertions, 0 deletions
diff --git a/src/mailsync.h b/src/mailsync.h index a826f43..d614dbc 100644 --- a/src/mailsync.h +++ b/src/mailsync.h @@ -18,6 +18,7 @@ #pragma once +#include <QDateTime> #include <QObject> #include <QProcess> #include <QString> @@ -76,6 +77,64 @@ enum class SyncOutcome { Failed, }; +/// What a finished run was, from the status file (item 174). +/// +/// Three states rather than SyncOutcome's two, and the third is the point: +/// a run that SKIPPED because another held the lock is neither a success nor a +/// failure, and having no way to say so is why item 125 left the spinner +/// running for ever. +enum class SyncState { + Unknown, + Ok, + Failed, + Skipped, +}; + +/// One finished run of the sync script, as the script itself reported it. +/// +/// This exists because the application used to INFER a finished run, from an +/// inode in /proc/locks and from grepping the log for its RUN END banner. That +/// made a human-readable line into wire format, and it could not answer the +/// question the pending count actually needs answered: which channels did this +/// run carry? The local sync path has always narrowed its clear to the accounts +/// it carried; the external path could not, and cleared everything. +/// +/// Written by assets/mailsync.sh, which is the only producer. The two agree by +/// TEST rather than by shared code, exactly as the two readers of rules.json +/// do: assets/test_mailsync.py pins the writer, test_mailsync.cpp pins the +/// reader, and one test runs the real script and reads what it wrote. +struct SyncStatus +{ + SyncState state = SyncState::Unknown; + + /// The channels the run synced. Empty when `everyChannel` is true. + QStringList channels; + + /// The run covered every account, which the script reports as "-a". + /// + /// Carried as a flag rather than left as the literal string in `channels`, + /// because "-a" is not a channel name: a caller matching it against + /// configured channels finds nothing and clears nothing, on exactly the run + /// that carried everything. + bool everyChannel = false; + + /// Reported separately as well as folded into `state`, because they mean + /// different things: a failed mbsync means the edits never reached the + /// server, while a failed notmuch means they did and only the local index + /// is behind. -1 for a run where neither program ran. + int mbsyncStatus = -1; + int notmuchStatus = -1; + + QDateTime started; + QDateTime ended; + + /// True only for a run that completed with both programs succeeding. + /// Nothing else may clear the pending count, per the rule the local path + /// states: clearing on a failure asserts the edits reached the mail store + /// when the sync is exactly what failed to put them there. + bool carriedEdits() const { return state == SyncState::Ok; } +}; + /// Runs the configured external sync command. /// /// qtmaildir deliberately does not implement sync itself. The existing script @@ -125,6 +184,27 @@ public: /// Anything unreadable, absent or unmarked is Unknown. static SyncOutcome lastRunOutcome(const QString &logPath); + /// Reads the status file assets/mailsync.sh writes (item 174). + /// + /// Preferred over lastRunOutcome(), which stays as the fallback for a file + /// that is missing or unreadable: that is what a first run after upgrading + /// looks like, and deleting a working mechanism in the same change that + /// adds its replacement leaves two broken things instead of one. + /// + /// Anything unreadable, absent, malformed or of an unrecognised version + /// returns a default SyncStatus, whose state is Unknown. Callers must + /// change no state on Unknown, exactly as they must for SyncOutcome and + /// SyncMonitor::State: it is the absence of evidence, not evidence of + /// absence. + /// + /// A whole-file read rather than a tail, unlike lastRunOutcome(): the file + /// holds one run and is a few hundred bytes, where the log holds every run + /// of the day. + static SyncStatus readStatus(const QString &statusPath); + + /// Where the status file lives when the config names none. + static QString defaultStatusPath(); + signals: void started(); void outputReceived(const QString &chunk); |
