The Worst Outcome Is "Done"
A transfer that fails loudly is an annoyance. You read the error and run it again. A transfer that says done while files are missing is a disaster you find weeks later, when the copy you trusted turns out to be incomplete. Delta's review on 2026-11-10 found exactly that path. One unreadable small file cut the source's bundle answer short. The worker thread that was reading it died on the cut. The job counted no errors, so at the end it reported done, with nothing landed.
The invariant that came out of it is short: a job never says done on silence. Every planned file either landed or is named in the job's errors. Making that true took four separate changes, because silence had four ways in.
1. A Worker Catches Everything
The dead worker had caught Delta's own DeltaError and OSError. The cut raised http.client.IncompleteRead, which is neither, so the exception ended the thread, and a thread that ends takes its unit of work with it. Now each worker catches three tiers and turns each into a line on the job:
| Caught | Recorded as |
|---|---|
DeltaError | <unit>: <cause>: <detail>, e.g. timeout, integrity, conflict |
OSError | <unit>: unreadable: <strerror> (<path>) |
| anything else | <unit>: internal: <type>: <message>, with the traceback in the log |
A bug in Delta is still a bug, but now it is a named failure on the job instead of a worker that quietly vanished.
2. One File's Error Is That File's
The source side changed too. When the bundle server cannot open one file, it now writes that file's header line with "error": "unreadable" and moves on to the next file. Before, the exception ended the response mid-stream and took every other file in the bundle with it. The push direction follows the same rule: a local file that will not open is recorded as its own error, and the rest of the bundle is still sent. The receiver also checks the other way round. A file it asked for that never appeared in the answer is failed by name: "the bundle did not carry it".
3. A Hiccup Is Tried Again, and Said So
On a weak link, every hiccup used to fail the file and end the job, to be run again by hand. Now a transient failure (cause unavailable or timeout) is tried again inside the job after 0.5, 2, 5 and 10 seconds. Every retry appends a note such as "retrying chunk 3 of model.bin in 2 s after: …" and increments a counter, and the CLI prints each note as it appears. Retrying is careful about what it repeats:
- a retried pull bundle asks only for the files that have not landed yet;
- a commit sent twice lands once and answers twice, so retrying the last step of a big file is safe;
- a bundle that names
uniqueor an expected version is never sent twice. The first send may have landed, and a second would take a second name or refuse itself.
Anything that is not transient (an integrity failure, a refusal, a conflict) is not retried. After the last delay, a transient failure becomes the file's error too.
4. The Final Count
The end of a job is now an audit, not a formality:
- cancelled →
interrupted, "run the same command again to resume"; - any error →
failed, "N problem(s), the rest landed; first: …"; - no errors, but fewer files landed than planned →
failed, "N of M file(s) neither landed nor reported an error"; - only then
done, with what moved, what was already in place, what was deleted, and any links left out or deletes that did not happen.
Step 3 found a bug the day it shipped. A sync counted the files it was about to delete among the files it should have in place, so any sync that deleted something could never reach its own total. Five delete tests failed on the lab Mac. The fix was a definition: a file to delete is not one of the files a job is to have in place. Deletes got the same honesty. One counts only when it happened, and one that did not is listed in not_deleted with its reason.
The Server's Half: Read Before You Refuse
One more silent failure lived on the server. When Delta refused a request (a forbidden path, a bad ticket) before reading its body, the unread body stayed in the connection. The next request on that keep-alive connection was parsed from the middle of it, and produced a 400 or 501 that nobody could explain. Now a refusal first drains the request body, up to 96 MiB (beyond that the connection is closed instead), and only then sends its answer. The caller reads the real reason, and the connection stays usable.