Skip to content

Commit c2f19e9

Browse files
authored
Merge pull request #521 from submersion-app/fix/sync-error-recovery-509
feat(sync): sync error recovery — /tmp base-file fix, Repair, Troubleshoot screen, cloud clear (#509)
2 parents 8afc49e + beb830e commit c2f19e9

20 files changed

Lines changed: 2322 additions & 109 deletions

docs/superpowers/plans/2026-07-07-sync-error-recovery.md

Lines changed: 809 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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**.

lib/core/services/sync/changeset_log/base_part_file_sink.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:crypto/crypto.dart';
55
import 'package:uuid/uuid.dart';
66

77
import 'package:submersion/core/services/sync/changeset_log/base_chunker.dart';
8+
import 'package:submersion/core/services/sync/changeset_log/sync_temp_dir.dart';
89

910
/// Downloads a base's parts one at a time into a single temp file, verifying
1011
/// integrity as bytes land so the whole base is never held in memory. Each part
@@ -13,7 +14,7 @@ import 'package:submersion/core/services/sync/changeset_log/base_chunker.dart';
1314
/// partial file and returns null (transient -> the caller retries next sync).
1415
class BasePartFileSink {
1516
BasePartFileSink({Future<Directory> Function()? tempDirProvider})
16-
: _tempDir = tempDirProvider ?? (() async => Directory.systemTemp);
17+
: _tempDir = tempDirProvider ?? resolveSyncTempDir;
1718

1819
final Future<Directory> Function() _tempDir;
1920
static const _uuid = Uuid();
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import 'dart:io';
2+
3+
import 'package:flutter/foundation.dart' show FlutterError;
4+
import 'package:flutter/services.dart' show MissingPluginException;
5+
import 'package:path_provider/path_provider.dart';
6+
7+
/// Directory for streaming-sync temp files (base export + assembled base parts).
8+
///
9+
/// Prefers the app-container temp dir via path_provider. A sandboxed or
10+
/// hardened-runtime macOS app is denied `Directory.systemTemp` (`/tmp`) ->
11+
/// `PathAccessException` errno 1 (issue #509), so `/tmp` must never be used in
12+
/// production.
13+
///
14+
/// Falls back to `systemTemp` ONLY when path_provider's plugin infrastructure
15+
/// is absent -- a plain unit test with no mocked channel
16+
/// ([MissingPluginException]) or no initialized test binding at all (the
17+
/// [FlutterError] "Binding has not yet been initialized"). In tests `/tmp` is
18+
/// writable, so this keeps them green without every one mocking path_provider.
19+
/// Any OTHER failure (e.g. a real [PlatformException] from the native side) is
20+
/// a genuine runtime problem and propagates, rather than silently
21+
/// reintroducing the `/tmp` EPERM this fix exists to avoid. In production
22+
/// getTemporaryDirectory resolves normally, so no fallback runs.
23+
Future<Directory> resolveSyncTempDir() async {
24+
try {
25+
return await getTemporaryDirectory();
26+
} on MissingPluginException {
27+
return Directory.systemTemp;
28+
} on FlutterError catch (e) {
29+
// ONLY the "no test binding" case (a unit test that never called
30+
// TestWidgetsFlutterBinding.ensureInitialized, so ServicesBinding.instance
31+
// throws). Any other FlutterError is a real problem and must surface, not
32+
// silently fall back to /tmp.
33+
if (e.toString().contains('Binding has not yet been initialized')) {
34+
return Directory.systemTemp;
35+
}
36+
rethrow;
37+
}
38+
}

lib/core/services/sync/library_epoch_store.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ class LibraryEpochStore {
4747
await _prefs.remove(_pendingReplaceKey);
4848
}
4949

50+
/// Wipe both epoch records. Used by the comprehensive local sync repair: the
51+
/// last-accepted marker is the only local sync state a DB reset does not
52+
/// touch (it lives in SharedPreferences), so a wedge that survives Reset
53+
/// needs this cleared for a true reinstall-equivalent.
54+
Future<void> clear() async {
55+
await _prefs.remove(_lastAcceptedMarkerKey);
56+
await _prefs.remove(_pendingReplaceKey);
57+
}
58+
5059
LibraryEpochMarker? _decode(String? raw) {
5160
if (raw == null) return null;
5261
try {

lib/core/services/sync/sync_data_serializer.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:drift/drift.dart';
66
import 'package:uuid/uuid.dart';
77

88
import 'package:submersion/core/database/database.dart';
9+
import 'package:submersion/core/services/sync/changeset_log/sync_temp_dir.dart';
910
import 'package:submersion/core/services/database_service.dart';
1011
import 'package:submersion/core/services/logger_service.dart';
1112

@@ -727,7 +728,7 @@ class SyncDataSerializer {
727728
DateTime Function() now = DateTime.now,
728729
Future<Directory> Function()? tempDir,
729730
}) async {
730-
final dir = await (tempDir?.call() ?? Future.value(Directory.systemTemp));
731+
final dir = await (tempDir?.call() ?? resolveSyncTempDir());
731732
final path =
732733
'${dir.path}/ssv1_base_${deviceId}_${seq ?? 0}.${_baseTempUuid.v4()}.json';
733734
final raf = await File(path).open(mode: FileMode.write);

0 commit comments

Comments
 (0)