A Stop Is Not a Failure Halfway Through
Most errors in a transfer happen to one file: it could not be read, a chunk stalled, a destination changed under the plan. Track 6 handles those by naming the file and letting the rest land. A two-way sync has a second class of problem, where the whole plan is untrustworthy. Carrying out any part of a plan you cannot trust is worse than carrying out none, so pair.py has a separate exception for it: Stop, documented as "a reason no retry clears by itself; nothing was changed."
Every stop is raised before the first keep, the first transfer and the first delete. The job ends failed, its cause is the stop's name, its message says what to do, and both trees are exactly as they were. Through the kit (kit_delta's pair()) every stop arrives as the same DeltaError, cause transfer_failed, with the stop's message as its text. The stop's own name is only in the job snapshot, so an app that must tell big_delete from clock_skew passes on_progress and reads cause from the last snapshot it was handed.
The Stops, One by One
| Cause | When | Why it stops instead of guessing |
|---|---|---|
clock_skew | the two clocks differ by more than max_skew_s (default 60 s) | newer-wins compares two Macs' mtimes; with a skewed clock "newer" picks the wrong side |
clock_unread | the peer's /v1/health carries no time (a Delta too old to report its clock) | a skew that cannot be measured cannot be bounded either |
archive_missing | no archive for this pair | a fresh archive over unequal trees resurrects deletions (lesson 1) |
archive_unreadable | an archive that does not parse, or is not one this Delta wrote | a corrupt memory is no memory |
pair_changed | the run names other roots or other excludes than the archive | one tree's history would be read against another tree |
big_delete | a whole tree, or more deletions one way than max(20, half the archive), gone on one side | an unmounted disk or a moved folder looks exactly like "delete everything" |
unreadable | a folder in either tree would not open | a folder that cannot be listed is not an empty folder |
root_missing | a pair's root does not exist on one side | same as a whole-tree delete, one step earlier |
trees_differ | init or verify found a difference | an archive must start from proven equality |
The clock is measured, not assumed. A peer's /v1/health carries its time; the run records when it asked and when the answer came, and takes the peer's time minus the midpoint, which cancels half the round trip. A follow-up commit the same day made every mode report the skew while only sync refuses on it: init and verify compare content and never pick a winner by time, so they have no reason to stop, but the number is still worth seeing.
big_delete is the stop that protects against the most ordinary accident. Someone renames the pair's folder, an external disk is not mounted, a restore is half done: from the sync's point of view, thousands of files were deleted on one side, and the rule says deletions cross. The limit is relative to what was agreed: with 2,873 files in the archive, up to 1,436 deletions one way is a normal (if large) run; 1,437 stops it. For small trees the floor of 20 keeps a handful of deletes from tripping it. And a side whose tree is entirely empty while the archive holds files always stops, whatever the count.
Agreement Only From the Version Still There
After a run moves files, it rebuilds the archive from a second listing of both trees. That is where a subtle bug would hide, and the guard against it was in the first commit, written into Delta's house sign as a gotcha so no later rewrite drops it. Suppose a file was hashed during the run (it was a maybe) and then edited before the run finished. If the new archive recorded the hash together with the new mtime from the second listing, the edit would be written down as already agreed, and it would never cross. So a hash vouches only for the version it read: an entry is refreshed only when the mtime seen in the second listing is the one the hash was taken against. Otherwise the last agreement stands, and the next run sees the edit as a change.
A Quiet Run Costs One Listing
Most runs of a pair that runs every minute have nothing to do. Before the 1110-7 review, even those listed both trees twice: once to plan, once to rebuild the archive. Now a run that changed nothing reads nothing back; the listings it planned from are the trees as it left them. For a pair that runs every minute, that halves the listing cost of every minute that did nothing, which matters when the same Mac also answers other apps' listings.