|
| 1 | +# Sync Error Recovery — Design |
| 2 | + |
| 3 | +Date: 2026-07-07 |
| 4 | +Issue: [#509](https://github.com/submersion-app/submersion/issues/509) (plus a related maintainer-observed dead-end) |
| 5 | + |
| 6 | +## Problem |
| 7 | + |
| 8 | +Users can land in a persistent Cloud Sync error with no in-app way out — a |
| 9 | +reinstall is currently the only escape. Two concrete triggers were reported: |
| 10 | + |
| 11 | +1. **"The replaced library is still uploading. Try again shortly."** |
| 12 | + (`sync_service.dart` `adoptReplacedLibrary`, ~line 2192). A device did a |
| 13 | + "Replace everywhere", bumping the library epoch, but the uploading device |
| 14 | + went **offline** before publishing the new base. Every other device sees the |
| 15 | + new epoch marker, finds no base for it (`sources.baseFilePaths.isEmpty`), |
| 16 | + and — because the marker is not stale/old-format, so `_recoverUnreadableEpoch` |
| 17 | + returns false — returns "still uploading, try again shortly" **forever**. |
| 18 | + |
| 19 | +2. **`PathAccessException: Cannot open file, path = '/tmp/ssv1_base_…json' |
| 20 | + (OS Error: Operation not permitted, errno = 1)`** (#509). The base export |
| 21 | + temp file is written to `Directory.systemTemp` (`sync_data_serializer.dart` |
| 22 | + `exportBaseToTempFile`, line 730; also `base_part_file_sink.dart` line 16), |
| 23 | + which resolves to `/tmp` on macOS. A hardened-runtime / sandboxed app can be |
| 24 | + denied access there (EPERM), so publishing/adopting a base fails on every |
| 25 | + sync. |
| 26 | + |
| 27 | +### Why existing recovery does not help |
| 28 | + |
| 29 | +"Reset Sync State" (`SyncService.resetSyncState`) only calls |
| 30 | +`_syncRepository.resetSyncState(clearDeletionLog: false)` — DB-level sync rows, |
| 31 | +cursors, and device identity. It does **not** clear the `LibraryEpochStore` |
| 32 | +(SharedPreferences keys `sync_last_accepted_epoch_marker` and |
| 33 | +`sync_pending_replace_marker`), nor any cloud-side epoch marker. So the next |
| 34 | +sync re-reads the same wedged epoch state and re-wedges. Deleting the database |
| 35 | +also leaves the SharedPreferences epoch keys intact. Only a full reinstall |
| 36 | +(which wipes the app container, including SharedPreferences) escaped — which is |
| 37 | +exactly what the #509 reporter had to do. |
| 38 | + |
| 39 | +## Goals |
| 40 | + |
| 41 | +- Give users a reliable, in-app **way out** of any wedged sync state without a |
| 42 | + reinstall and without losing dive data. |
| 43 | +- Fix the two specific dead-ends at the source so they stop being terminal. |
| 44 | +- Let a user reclaim cloud space by clearing this device's — or all — sync |
| 45 | + artifacts on the active backend. |
| 46 | + |
| 47 | +## Non-goals |
| 48 | + |
| 49 | +- No change to the normal sync/merge/replace happy paths. |
| 50 | +- No multi-backend fan-out: cloud-clear operates on the **active** provider only. |
| 51 | +- No automatic background repair; recovery is user-initiated (except the two |
| 52 | + targeted fixes, which make specific failures non-terminal). |
| 53 | + |
| 54 | +## Surfaces |
| 55 | + |
| 56 | +- New **Troubleshoot Sync** screen, opened from the Cloud Sync page's *Advanced* |
| 57 | + section (`cloud_sync_page.dart` `_buildAdvancedSection`). |
| 58 | +- The existing **"Sync error" banner becomes tappable** and routes to that |
| 59 | + screen, so a stuck user finds the exit where the error is shown. |
| 60 | + |
| 61 | +The existing "Reset Sync State" and "Sign Out" entries remain unchanged; the new |
| 62 | +recovery actions live on the Troubleshoot screen with escalating severity and |
| 63 | +plain-language descriptions so users pick the right one. |
| 64 | + |
| 65 | +## Component 1 — Repair Sync (comprehensive local reset) |
| 66 | + |
| 67 | +The guaranteed escape: a new `SyncService.repairLocalSyncState()` that clears |
| 68 | +**all** local sync state without touching dive data — everything a reinstall |
| 69 | +clears, minus the reinstall. |
| 70 | + |
| 71 | +Clears: |
| 72 | +- `_syncRepository.resetSyncState(clearDeletionLog: false)` — which already |
| 73 | + clears sync records, **peer cursors, and all `local_publish_states` rows** |
| 74 | + (verified: `sync_repository.dart` deletes `syncPeerCursors` and |
| 75 | + `localPublishStates`), plus assigns a new device identity. The deletion log is |
| 76 | + preserved so deletions don't resurrect, consistent with today's Reset. |
| 77 | +- **`LibraryEpochStore.clear()`** (new): removes both |
| 78 | + `sync_last_accepted_epoch_marker` and `sync_pending_replace_marker`. This is |
| 79 | + the **only** local sync state `resetSyncState` does not touch (it lives in |
| 80 | + SharedPreferences, not the DB) — the reinstall-only survivor and the crux of |
| 81 | + why today's "Reset Sync State" fails to clear a wedge. |
| 82 | +- Best-effort deletion of leftover base temp files (see Component 2's temp dir). |
| 83 | +- Clears the persisted/in-memory sync error so the banner goes away. |
| 84 | + |
| 85 | +In short: today's Reset already nukes the DB-side sync state; Repair adds the |
| 86 | +SharedPreferences epoch markers (and temp/error cleanup) to make it a true |
| 87 | +reinstall-equivalent. |
| 88 | + |
| 89 | +After repair the device is a fresh participant; the next sync runs the normal |
| 90 | +first-sync merge flow. Repair does **not** auto-trigger a sync — the user taps |
| 91 | +"Sync Now" when ready. UI confirmation stresses that dive data is safe and only |
| 92 | +the sync bookkeeping is reset. |
| 93 | + |
| 94 | +## Component 2 — Targeted fixes for the two dead-ends |
| 95 | + |
| 96 | +### 2a. Offline-uploader adopt wait becomes actionable |
| 97 | + |
| 98 | +When `adoptReplacedLibrary()` finds no base for a **non-stale** epoch, instead of |
| 99 | +the terminal "still uploading, try again shortly", surface a distinct *waiting* |
| 100 | +outcome the Troubleshoot screen presents with an action: |
| 101 | + |
| 102 | +> "The other device may be offline. Rebuild this backend from **this** device's |
| 103 | +> library." |
| 104 | +
|
| 105 | +Choosing it republishes this device's library as the current epoch's base |
| 106 | +(reusing the existing re-establish path — the same mechanism |
| 107 | +`_recoverUnreadableEpoch` uses today, generalized to a user-driven choice rather |
| 108 | +than only auto-firing for stale/old-format markers). "Try again shortly" |
| 109 | +remains available for the case where the uploader is merely slow, not gone. |
| 110 | + |
| 111 | +### 2b. Sync temp files use the app-container temp dir; base failures are non-fatal |
| 112 | + |
| 113 | +- Default the temp dir for `exportBaseToTempFile` (`sync_data_serializer.dart`) |
| 114 | + and `BasePartFileSink` (`base_part_file_sink.dart`) to |
| 115 | + `path_provider`'s `getTemporaryDirectory()` (always accessible app-container |
| 116 | + temp) instead of `Directory.systemTemp` (`/tmp`). The existing injectable |
| 117 | + `tempDir`/`tempDirProvider` seams are kept for tests; only the default changes. |
| 118 | +- A base open/read/write failure is logged and treated as **transient** (retry |
| 119 | + next sync) rather than surfacing as a terminal sync error that recurs every |
| 120 | + attempt. Combined with Component 1, no base-file problem can permanently wedge |
| 121 | + a device. |
| 122 | + |
| 123 | +## Component 3 — Cloud clear on the active provider (two distinct actions) |
| 124 | + |
| 125 | +Both operate on the **active** provider only and are exposed on the Troubleshoot |
| 126 | +screen with clearly different severities. |
| 127 | + |
| 128 | +### 3a. Remove this device's sync files (safe) |
| 129 | + |
| 130 | +`SyncService.deleteDeviceSyncFile(thisDeviceId)` already retires this device's |
| 131 | +changeset log; extend it to also remove this device's base parts and any |
| 132 | +conflict copies. Other devices keep syncing; frees this device's share of the |
| 133 | +folder. `thisDeviceId` from `SyncRepository.getDeviceId()`. Standard |
| 134 | +confirmation. |
| 135 | + |
| 136 | +### 3b. Wipe ALL sync data on this backend (destructive) |
| 137 | + |
| 138 | +`SyncService.deleteAllSyncFiles(provider)` already wipes changeset logs |
| 139 | +(`ssv1.*`) and legacy full-file uploads, but **deliberately preserves** the |
| 140 | +`submersion_library_*` epoch/moved markers. For a genuine fresh start this action |
| 141 | +additionally deletes those epoch markers. Every device re-establishes from |
| 142 | +scratch. Guarded by an extra/typed confirmation distinct from 3a. |
| 143 | + |
| 144 | +## Data / state inventory (what "wedged" state lives where) |
| 145 | + |
| 146 | +| State | Location | Cleared by Reset today? | Cleared by Repair (C1)? | |
| 147 | +|-------|----------|-------------------------|-------------------------| |
| 148 | +| Sync records, peer cursors, device id | main DB | yes | yes | |
| 149 | +| `local_publish_states` (all providers) | main DB | yes | yes | |
| 150 | +| Last-accepted / pending-replace epoch markers | **SharedPreferences** | **no** | **yes** | |
| 151 | +| Deletion log | main DB | kept by design | kept by design | |
| 152 | +| Base temp files | temp dir | n/a | best-effort delete | |
| 153 | +| Cloud epoch markers + per-device files | cloud | no | no (that's Component 3) | |
| 154 | + |
| 155 | +## Error handling |
| 156 | + |
| 157 | +- Every recovery action is best-effort and idempotent: partial failure (e.g. |
| 158 | + provider offline during a cloud clear) is logged, reported to the user, and |
| 159 | + safe to re-run. Cloud clears already swallow per-file errors. |
| 160 | +- Repair must complete the local clears even if the active provider is |
| 161 | + unreachable (local state is the point). |
| 162 | + |
| 163 | +## Testing |
| 164 | + |
| 165 | +- **Unit** (`SyncService` / stores with fakes): |
| 166 | + - `repairLocalSyncState` clears each store (sync repo, pending, epoch prefs, |
| 167 | + publish state) and the error; deletion log preserved. |
| 168 | + - `LibraryEpochStore.clear()` removes both keys. |
| 169 | + - `adoptReplacedLibrary` with no base for a non-stale epoch returns the new |
| 170 | + actionable *waiting* outcome (not a bare error), and the rebuild action |
| 171 | + republishes the local library. |
| 172 | + - `exportBaseToTempFile` / `BasePartFileSink` default to the app temp dir; a |
| 173 | + base file error is non-fatal. |
| 174 | + - 3a targets only this device's files; 3b also deletes epoch markers. |
| 175 | +- **Widget** (`cloud_sync_page` / Troubleshoot screen): |
| 176 | + - Advanced → Troubleshoot navigation; tappable error banner routes there. |
| 177 | + - Each action shows its confirmation; destructive 3b requires the stronger |
| 178 | + confirmation. |
| 179 | + |
| 180 | +## Sizing / delivery |
| 181 | + |
| 182 | +Cohesive but sizable — one spec, delivered as a small stack of two PRs so each |
| 183 | +stays reviewable: |
| 184 | + |
| 185 | +- **PR 1**: temp-dir fix (2b) + Repair Sync core (C1, incl. `LibraryEpochStore.clear`) |
| 186 | + + Troubleshoot screen scaffold + tappable error banner. |
| 187 | +- **PR 2**: cloud-clear actions (C3) + offline-uploader actionable escape (2a). |
| 188 | + |
| 189 | +## Resolved decisions |
| 190 | + |
| 191 | +- "Wipe all" (3b) **also clears epoch markers** for a true fresh start. |
| 192 | +- Repair Sync **does not auto-trigger a sync**; the user syncs when ready. |
| 193 | +- Cloud clear targets the **active provider only**. |
0 commit comments