diff --git a/docs/explanations/2026-07-16-explanation-surface-sync-outcomes.html b/docs/explanations/2026-07-16-explanation-surface-sync-outcomes.html new file mode 100644 index 0000000..df84e4d --- /dev/null +++ b/docs/explanations/2026-07-16-explanation-surface-sync-outcomes.html @@ -0,0 +1,480 @@ + + +
+ + +The todo app is an offline-first notes app that runs on two devices — a Linux
+desktop and an Android phone — and keeps them in sync through a private GitHub
+repo used as dumb storage. There is no server: each device owns exactly one
+file, todo-sync/notes/<nodeId>.json, holding its full CRDT
+log of notes. A sync is a pull-merge-push cycle: list the directory, merge
+every other device's file into the local store, then upload your own
+merged log. Because merging is commutative and idempotent, any two devices that
+both complete a sync end up with the same notes.
That last sentence carries a hidden assumption: that syncs actually +complete, and that you'd know if they didn't. Before this change, the +capture screen triggered sync in three places:
+ +paused,Three separate silences hid failures:
+ +_autoSync() wrapped the whole cycle in catch (_)
+ — every automatic failure (offline, bad token, GitHub error) vanished.SyncService.sync(), a peer file that failed to fetch or
+ parse was skipped with a bare continue. The sync then
+ succeeded from the caller's point of view — it just quietly merged
+ nothing from that device.paused lifecycle
+ event at all, so the desktop effectively only synced at launch or on a
+ manual button press.These silences had just produced a real incident: after the July 11 storage +migration (which moved the sync location to a new, incompatible directory), the +phone got the new build but the desktop kept running a bundle built two hours +before the migration commit. For five days each device synced happily into a +directory the other never read — desktop showed 39 notes, phone 30 — and no +pixel anywhere said anything was wrong.
+ +The fix is not smarter syncing — the CRDT merge was never the problem. The +fix is making every sync outcome observable, and making sure +the desktop actually gets chances to sync. One sentence: a sync system that +can fail silently will eventually diverge silently; a status line is the +cheapest possible alarm.
+ +Concretely, imagine the sync repo contains your own file plus two peers, one +of them corrupt:
+ +{not a logBefore the change both outcomes below rendered identically as
+"Synced: merged 1 device(s)" — you could not tell a healthy fleet
+from one silently losing a device. Now the same run reports:
and an automatic sync that fails outright no longer disappears into
+catch (_) — it lands in a small persistent status line under the
+editor, in the error color:
The status is also written to SharedPreferences, so a failure
+that happened just before the app closed is still visible the next morning.
+Had this line existed on July 11, the desktop would have shown a stale
+"Synced at …" timestamp days old — the divergence would have been visible on
+day one instead of day five.
Reporting outcomes only helps if syncs actually run. On Android,
+paused fires reliably when you switch apps. On Linux desktop it
+rarely fires — but inactive and hidden (focus loss)
+do. The catch: focus flickers constantly on a desktop (alt-tab, clicking
+another window), and firing a GitHub round-trip per flicker would be abusive.
+So the new trigger is debounced: focus must stay lost for 5
+seconds before a sync fires, and every new lifecycle event resets the timer.
+paused still syncs immediately — Android may kill a paused app
+before any timer fires, so waiting would risk losing the push.
sync_service.dart)SyncResult gains a skippedFiles list, and the pull
+loop collects instead of discarding. Note the two failure shapes it now covers
+with one expression: a file that was listed but whose GET returned nothing
+(text == null), and one that fetched but didn't parse as a log:
- final text = await github.getFileText('$notesDir/$name'); +- if (text == null) continue; +- final remote = _decodeLog(text); +- if (remote == null) continue; // Skip a corrupt/foreign file.+ final text = await github.getFileText('$notesDir/$name'); ++ final remote = text == null ? null : _decodeLog(text); ++ if (remote == null) { ++ // A vanished or undecodable peer file must not abort the sync, but ++ // it must not vanish silently either — that's how a device drops ++ // out of sync unnoticed. Report it in the result instead. ++ skipped.add(name); ++ continue; ++ }
The tolerance is unchanged — one bad file still never aborts the cycle, and +the push still happens — but the caller now knows the merge was partial.
+ +capture_screen.dart)A tiny SyncStatus value (time, ok, detail) flows into a
+ValueNotifier — the same pattern the screen already used for the
+"Saved locally at…" indicator, so one status change repaints one line of text,
+not the whole screen. _recordSyncStatus() also persists the three
+fields to SharedPreferences, and _restoreSyncStatus()
+reloads them in initState.
Future<void> _autoSync() async {
+ ...
+ try {
+- await _syncService.sync(widget.repository, client);
+- } catch (_) {
+- // Best-effort: ignore (offline, transient GitHub errors, etc.).+ final result = await _syncService.sync(widget.repository, client);
++ await _recordSyncStatus(ok: true, detail: _describe(result));
++ } catch (e) {
++ // Offline or a transient GitHub error: still no snackbar (this path
++ // must never interrupt capture), but the status line shows it.
++ await _recordSyncStatus(ok: false, detail: '$e');
+ } finally { ... }
+}The design line to notice: auto-sync is still quiet (no snackbar — +capture must never be interrupted), but it is no longer silent. The +manual Sync button records the same status and shares the same message builder, +so the snackbar and the status line can never drift apart:
+ +static String _describe(SyncResult result) {
+ final skipped = result.skippedFiles.length;
+ return 'merged ${result.mergedDevices} device(s)'
+ '${skipped == 0 ? '' : ', skipped $skipped unreadable file(s)'}';
+}if (state == AppLifecycleState.paused) {
+ // Immediate: Android may kill the app before a debounce timer fires.
+ _autoSyncDebounce?.cancel();
+ _autoSync();
+} else if (state == AppLifecycleState.inactive ||
+ state == AppLifecycleState.hidden) {
+ // Focus loss — the only background signal Flutter Linux delivers
+ // reliably (`paused` rarely arrives on desktop). Debounced so alt-tab
+ // flicker doesn't fire a request per focus change.
+ _autoSyncDebounce?.cancel();
+ _autoSyncDebounce = Timer(CaptureScreen.autoSyncDebounce, _autoSync);
+}The timer is cancelled in dispose(), and the existing
+single-flight guard (_autoSyncing) still prevents overlapping
+cycles if a debounced sync lands while another is running. The 5-second
+constant lives on the public widget class so tests can pump exactly past it.
The suite grew from 173 to 174 tests, still at 100% line coverage. The old +test "auto-sync failure is silent (no snackbar)" flipped meaning: it now +asserts no snackbar but a visible error status line. New tests cover +the skipped-file message, restoring a persisted failure on launch, and the +debounce (two focus events reset the timer; the sync fires once, only after +the full window elapses).
+ +Five questions, click an answer to check it. Score: 0 / 0
+ +1. Why did the desktop/phone divergence stay invisible for five days?
+ + + + + +2. A peer file appears in the directory listing but its GET returns 404. What does the new sync do?
+ + + + + +3. Why does paused sync immediately while inactive/hidden are debounced?
4. Right after a fresh app launch, before any sync runs, where does the status line's content come from?
+ + + + + +5. Manual Sync reports "merged 1 device(s), skipped 1 unreadable file(s)". What should you conclude?
+ + + + + +1 explanation · interactive walkthroughs of notable changes, newest first.
+2 explanations · interactive walkthroughs of notable changes, newest first.