From 8c4e695dae3d1bf71f8b0b8ddac4fca7c08bad98 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Thu, 16 Jul 2026 18:08:39 +0200 Subject: [PATCH] Add change explanation: surface sync outcomes Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K5C5jZ2c4mTaGedK7C1dDv --- ...-16-explanation-surface-sync-outcomes.html | 480 ++++++++++++++++++ docs/explanations/index.html | 6 +- 2 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 docs/explanations/2026-07-16-explanation-surface-sync-outcomes.html 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 @@ + + + + + +Surface sync outcomes so devices can't silently diverge + + + + +

Surface sync outcomes so devices can't silently diverge

+

Commit 7564a65 · 2026-07-16 · lib/sync/sync_service.dart, lib/ui/capture_screen.dart + tests

+ + + +

1 · Background

+ +

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:

+ + + +

Three separate silences hid failures:

+ +
    +
  1. _autoSync() wrapped the whole cycle in catch (_) + — every automatic failure (offline, bad token, GitHub error) vanished.
  2. +
  3. Inside 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.
  4. +
  5. On Linux desktop, Flutter rarely delivers the paused lifecycle + event at all, so the desktop effectively only synced at launch or on a + manual button press.
  6. +
+ +

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.

+ +

2 · Intuition

+ +

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:

+ +
+
phone.jsonvalid log · 42 notes
merged
+
tablet.json{not a log
undecodable
+
desktop.json(your own file)
pushed
+
+ +

Before 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:

+ +
Synced at 18:00:35 · merged 1 device(s), skipped 1 unreadable file(s)
+ +

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:

+ +
Sync failed at 07:12:03 · GitHubSyncError: GET todo-sync/notes failed: 401
+ +

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.

+ +

The second half: the desktop never got a turn

+ +

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.

+ +
+

Micro-world: the new lifecycle trigger

+

Fire lifecycle events and step time + forward one second at a time. This mirrors the real + didChangeAppLifecycleState logic: paused syncs now; + inactive/hidden arm (or reset) a 5-second timer; + resumed does nothing — a timer already armed still fires.

+
+ + + + + + +
+
t = 0 s · + debounce timer: none · + syncs fired: 0
+
— try: inactive → inactive → advance ×5 (timer resets, then fires once)
+
+ +
+

Micro-world: what the sync message reports

+

Set each peer file's condition and + see the exact message the snackbar and status line build (the real + _describe() logic over SyncResult).

+
+
+ +
+
+
+
+ +

3 · Code

+ +

Concern 1 — skipped peers become data, not silence (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.

+ +

Concern 2 — every outcome lands somewhere visible (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)'}';
+}
+ +

Concern 3 — a sync trigger the desktop actually receives

+ +
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.

+ +

Concern 4 — tests

+ +

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).

+ +

4 · Quiz

+

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?

+ + + + +

+
+ + + + diff --git a/docs/explanations/index.html b/docs/explanations/index.html index 3386b2e..36a95d7 100644 --- a/docs/explanations/index.html +++ b/docs/explanations/index.html @@ -30,8 +30,12 @@
todo

Change explanations

-

1 explanation · interactive walkthroughs of notable changes, newest first.

+

2 explanations · interactive walkthroughs of notable changes, newest first.