diff --git a/CHANGELOG.md b/CHANGELOG.md index a820ed4..7497ff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,16 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). ### Fixed +- **Watch: a queued offline switch can no longer be created twice.** The watch + app, its data-layer service and its tile trampoline each drained the offline + switch queue with their own lock, so two of them could submit the same queued + switch at once. They now share one lock across the whole app. + +- **Phone: syncing offline actions no longer risks duplicating a front.** A new + sync could previously cancel one already in progress at the moment it was + creating a front, which could create it twice; a running sync is now left to + finish. + - **Signing into another account no longer leaks the previous account's data.** Signing in or out now clears the local response cache and the offline action queue, so a new account can't briefly see the prior account's members, fronts diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/sync/SyncWorker.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/sync/SyncWorker.kt index 043c66d..47a8813 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/sync/SyncWorker.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/sync/SyncWorker.kt @@ -121,9 +121,19 @@ class SyncWorker @AssistedInject constructor( .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, java.util.concurrent.TimeUnit.SECONDS) .build() + // KEEP, not REPLACE: a drain that is already running must not be + // cancelled by a fresh enqueue. createFront is posted before its row + // is deleted (see doWork), so a REPLACE cancellation mid-drain is + // exactly the window that can duplicate a front. With KEEP, an + // enqueue while a run is in flight is dropped rather than cancelling + // it; any rows added after that run snapshotted the queue are picked + // up by the next schedule() (each offline action, plus reconnect, + // calls this). Trading a little latency on the tail for not + // double-creating fronts. True exactly-once still needs the backend + // Idempotency-Key noted in doWork. WorkManager.getInstance(context).enqueueUniqueWork( WORK_NAME, - ExistingWorkPolicy.REPLACE, + ExistingWorkPolicy.KEEP, request, ) } diff --git a/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearStore.kt b/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearStore.kt index 1150ecb..c1bb338 100644 --- a/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearStore.kt +++ b/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearStore.kt @@ -33,8 +33,9 @@ class WearStore( // Serialises the offline-switch queue drain. loadAll()/refreshNow() run from // many triggers (onResume, nav, manual refresh, post-switch, phone nudge); // without this, two concurrent drains could both submit the same queued row - // before either removed it, double-creating a front. - private val queueDrainMutex = Mutex() + // before either removed it, double-creating a front. The mutex lives in the + // companion object (see below) so it is shared across every WearStore in the + // process. val frontingMembers: List get() { @@ -278,6 +279,16 @@ class WearStore( // or the complications aren't currently in use, no harm done. systems.lupine.sheaf.wear.complications.requestAllComplicationUpdates(context) } + + companion object { + // Process-wide, not per-instance: the activity, the Data Layer service + // and the quick-switch trampoline each build their own WearStore, but + // they drain one shared SharedPreferences queue. A per-instance mutex + // let two of them snapshot and re-submit the same row, double-creating a + // front. Mirrors WearApiClient.refreshMutex, which was hoisted for the + // same reason. + private val queueDrainMutex = Mutex() + } } // ── Shared tile_data writers ────────────────────────────────────────────────