-
Notifications
You must be signed in to change notification settings - Fork 1.7k
fix(mobile): preserve channel group edits and sync sorting #2829
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,6 +48,17 @@ class ChannelSectionsManager { | |
| void Function()? _unsubscribe; | ||
| bool _disposed = false; | ||
|
|
||
| /// Unix seconds of the oldest unpublished local edit; 0 when clean. | ||
| /// Persisted so unpublished edits survive manager teardown — the provider | ||
| /// rebuilds this manager on every relay-session status flip, which would | ||
| /// otherwise drop the pending debounce and let a stale remote blob clobber | ||
| /// newer local state on the next fetch. | ||
| int _dirtySince; | ||
|
|
||
| /// Bumped on every local edit so a publish only clears the dirty flag when | ||
| /// no new edit landed while the publish was in flight. | ||
| int _editGeneration = 0; | ||
|
|
||
| ChannelSectionsManager({ | ||
| required this.pubkey, | ||
| required SharedPreferences prefs, | ||
|
|
@@ -62,10 +73,14 @@ class ChannelSectionsManager { | |
| _signedEventRelay = signedEventRelay, | ||
| _remoteEnabled = remoteEnabled, | ||
| _onChanged = onChanged, | ||
| _store = ChannelSectionsStorage(prefs).read(pubkey); | ||
| _store = ChannelSectionsStorage(prefs).read(pubkey), | ||
| _dirtySince = ChannelSectionsStorage(prefs).readDirtySince(pubkey); | ||
|
|
||
| ChannelSectionStore get store => _store; | ||
|
|
||
| @visibleForTesting | ||
| bool get isDirty => _dirtySince > 0; | ||
|
|
||
| Future<void> initialize() async { | ||
| if (_disposed) return; | ||
|
|
||
|
|
@@ -74,8 +89,19 @@ class ChannelSectionsManager { | |
| return; | ||
| } | ||
|
|
||
| await _fetchAndMerge(); | ||
| final foundRemote = await _fetchAndMerge(); | ||
| await _startLiveSubscription(); | ||
|
|
||
| // Publish-on-reconnect: unpublished local edits (persisted dirty flag) | ||
| // survive teardown/rebuild and get flushed once we're connected again. | ||
| // Seed-publish: if the relay confirmed it has no blob at all but local | ||
| // state exists, push local up so other clients can sync. `foundRemote` | ||
| // is null when the fetch failed — never seed-publish on a failed fetch. | ||
| if (_dirtySince > 0 || | ||
| (foundRemote == false && | ||
| (_store.sections.isNotEmpty || _store.assignments.isNotEmpty))) { | ||
| markDirty(); | ||
| } | ||
| _onChanged(); | ||
| } | ||
|
|
||
|
|
@@ -193,16 +219,27 @@ class ChannelSectionsManager { | |
| } | ||
|
|
||
| void markDirty() { | ||
| if (!_remoteEnabled || _disposed) return; | ||
| if (_disposed) return; | ||
| // Record dirtiness even while offline (remote disabled) — the flag is | ||
| // persisted, so the reconnect-time manager re-publishes instead of | ||
| // letting the stale remote blob clobber offline edits. | ||
| if (_dirtySince == 0) { | ||
| _dirtySince = currentUnixSeconds(); | ||
| _storage.writeDirtySince(pubkey, _dirtySince); | ||
| } | ||
| _editGeneration++; | ||
| if (!_remoteEnabled) return; | ||
| _publishDebounce?.cancel(); | ||
| _publishDebounce = Timer(const Duration(seconds: 5), () { | ||
| _publishDebounce = null; | ||
| unawaited(_publish()); | ||
| }); | ||
| } | ||
|
|
||
| Future<void> _fetchAndMerge() async { | ||
| if (_relaySession == null) return; | ||
| /// Returns whether the relay reported a `channel-sections` blob, or null | ||
| /// when the fetch failed (offline / relay error). | ||
| Future<bool?> _fetchAndMerge() async { | ||
| if (_relaySession == null) return null; | ||
| try { | ||
| final events = await _relaySession.fetchHistory( | ||
| NostrFilter( | ||
|
|
@@ -217,8 +254,12 @@ class ChannelSectionsManager { | |
| _mergeEvents(events); | ||
| _persist(); | ||
| if (!_disposed) _onChanged(); | ||
| return events.any( | ||
| (e) => e.pubkey == pubkey && e.getTagValue('d') == 'channel-sections', | ||
| ); | ||
| } catch (_) { | ||
| // Local state remains usable when relay is unavailable. | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -269,6 +310,13 @@ class ChannelSectionsManager { | |
| if (isNewer) { | ||
| _lastRemoteCreatedAt = event.createdAt; | ||
| _lastRemoteEventId = event.id; | ||
| // Dirty-state protection: never let a remote blob overwrite | ||
| // unpublished local edits. Whole-blob LWW would otherwise adopt a | ||
| // stale remote store fetched right after a teardown/rebuild and | ||
| // silently drop the just-made edit. We still advance | ||
| // _lastRemoteCreatedAt above so our eventual publish sorts after the | ||
| // remote event; the pending publish then reconciles the relay. | ||
| if (_dirtySince > 0) return; | ||
| _store = incoming; | ||
| _persist(); | ||
| } | ||
|
|
@@ -311,11 +359,18 @@ class ChannelSectionsManager { | |
| return; | ||
| } | ||
|
|
||
| // Read-before-write: merge remote state before publishing | ||
| final generationAtStart = _editGeneration; | ||
|
|
||
| // Read-before-write: advance _lastRemoteCreatedAt past any remote blob so | ||
| // our event sorts after it. Dirty-state protection in _mergeEvent keeps | ||
| // the fetched blob from clobbering the unpublished local store. | ||
| await _fetchAndMerge(); | ||
|
|
||
| // No-op suppression: skip if nothing changed | ||
| if (_isIdenticalToLastPublished()) return; | ||
| if (_isIdenticalToLastPublished()) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MINOR — (Anchored at the call site; the defect is in the method body at lines 348-351, unchanged by this PR but newly load-bearing now that this branch clears the persisted dirty flag.) The loop iterates only The consequence is a suppressed publish, so the relay keeps the old assignment map and the local change is silently not synced — and with the dirty flag cleared on that path (line 371), it isn't retried either. Low likelihood, real consequence. Fix: check containment in both directions. if (last.assignments.length != _store.assignments.length) return false;
for (final entry in _store.assignments.entries) {
if (!last.assignments.containsKey(entry.key)) return false;
if (last.assignments[entry.key] != entry.value) return false;
}The positional comparison of |
||
| _clearDirty(generationAtStart); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MINOR — clearing the dirty flag on the no-op-suppression path is safe today only by accident.
But it's incidental rather than stated. If One reviewer initially rated this CRITICAL on the grounds that Fix: either add a comment making the Same code at |
||
| return; | ||
| } | ||
|
|
||
| try { | ||
| final payload = jsonEncode(_store.toJson()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMPORTANT — one future-dated remote event permanently wedges publishing for this d-tag. (Anchored here; the line in question is
So a single blob published with This codebase already solved this. bool _isPlausibleCreatedAt(int createdAt) =>
createdAt <= currentUnixSeconds() + readStateMaxClockDriftSeconds;guarding its own cursor advance at Fix: reuse the existing helper — refuse to advance Same code at |
||
|
|
@@ -337,11 +392,22 @@ class ChannelSectionsManager { | |
| sections: List.of(_store.sections), | ||
| assignments: Map.of(_store.assignments), | ||
| ); | ||
| _clearDirty(generationAtStart); | ||
| } catch (error) { | ||
| debugPrint('[ChannelSectionsManager] publish failed: $error'); | ||
| // Dirty flag stays set; the next initialize() re-schedules the publish. | ||
| } | ||
| } | ||
|
|
||
| /// Clears the persisted dirty flag unless a new edit landed while the | ||
| /// publish that succeeded was in flight. | ||
| void _clearDirty(int generationAtStart) { | ||
| if (_editGeneration != generationAtStart) return; | ||
| if (_dirtySince == 0) return; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMPORTANT — the dirty flag is persisted per-pubkey but
The race:
The persisted flag is shared state; the guard protecting it is not. Fix: make the persisted value the thing you compare against — store a monotonic edit token alongside Also note the flush is only attempted when Same code at |
||
| _dirtySince = 0; | ||
| _storage.writeDirtySince(pubkey, 0); | ||
| } | ||
|
|
||
| void _persist() { | ||
| _storage.write(pubkey, _store); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CRITICAL — dirty-state protection silently and permanently discards legitimate remote edits, then overwrites them. Flagged independently by all three reviewers.
The ordering is the defect. When
isNeweris true, this advances_lastRemoteCreatedAtand_lastRemoteEventIdto the incoming event, and then line 319 bails withif (_dirtySince > 0) return;— droppingincomingentirely. Walk a real sequence:_publishcalls_fetchAndMerge(line 363), pulls the desktop event, sets_lastRemoteCreatedAt = T+10and_lastRemoteEventId = <desktop id>, then returns here without storing it.createdAt = max(currentUnixSeconds(), _lastRemoteCreatedAt + 1)(line 378) — deliberately sorting above desktop's event — carrying a blob that never contained desktop's new group.It is also unrecoverable, not merely deferred. Because
_lastRemoteEventIdwas advanced in step 3, a later redelivery of that same desktop event fails theisNewertest forever — even after the dirty flag clears.The comment at 313-318 says "the pending publish then reconciles the relay." It doesn't reconcile; it overwrites. That's the crux: a local dirty veto on the read combined with an unconditional win on the write is guaranteed clobber, not conflict resolution. The PR's goal is preventing edit loss on mobile, and this achieves it by relocating the identical bug onto desktop. Whole-blob LWW plus a one-sided veto can't be made correct.
Severity is amplified by the blob shape:
channel-sectionsis one whole-blob record, so "one dropped event" means every section and assignment the other client created in it.Fix (preferred) — bail before touching the cursor, and stash the dropped state for reconciliation:
Then field-merge
_deferredRemoteinto_storebefore publishing, so both edits survive.Fix (minimum, if a real merge is out of scope for this PR) — move the bail above the two assignments so the event stays re-adoptable once clean, and don't let the publish
createdAtleapfrog an unmerged remote event. That leaves a window where the two clients disagree, but nothing is destroyed.Note this applies identically at
channel_sort_manager.dart:286-290, which is the same code — see the duplication note in the review summary.