fix: async-safe repository construction, backup policy, moderation hydration + cancellation (P1-5, P1-6, P2-1, P2-2) #99
No reviewers
Labels
No labels
auth
bug
composer
correctness
database
durability
enhancement
feeds
interactions
notifications
performance
reliability
safety
security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
starsetbyte/peregrine!99
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/p1-startup-and-backup"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes P1-5, P1-6, P2-1, P2-2 (moderation half) from the 2026-07-11 adversarial delta review.
P1-5 — repository construction no longer blocks on Room/network
runBlocking/I/O fromModerationActionsRepository.init; local state derives from Room flows and expired-mute cleanup runs in an explicit suspend initializer._mutedDids/_blockedDidssets, so every mutation (muteAccount,unmuteAccount,blockAccount,unblockAccount,pruneExpiredMutes) was converted toupdate { }for atomicity.P1-6 — Android backup disabled by default
android:allowBackup="false"inAndroidManifest.xml, with an explanatory comment, since restore isn't a tested feature yet.P2-1 — moderation hydration reconciles stale Room rows
hydrateFromServernow deletes Room rows absent from a completed server snapshot instead of only upserting. Reconciliation is delete-after-complete-snapshot (not a Room transaction, since DAOs are interface-mocked in the JVM suite) — the property that matters, a partial snapshot never deletes, holds.P2-2 (moderation half) — cancellation-safe mutation + hydration serialization
ModerationActionsRepositorymutation methods (plushidePost/pruneExpiredMutes/hydrateFromServer) converted torunCatchingCancellable/attemptWithRevertso cancellation is rethrown, not swallowed.hydrateFromServer's reconcile could delete a Room row for an account muted/blocked concurrently, since the server snapshot predates the mutation — was deferred into this task rather than fixed in isolation. AhydrationMutexnow serializeshydrateFromServer's full body againstmuteAccount/unmuteAccount/blockAccount/unblockAccount(deliberately excludingmuteThread/unmuteThread, an unseeded field). Deadlock-freedom and race closure independently re-verified by an Opus-tier reviewer.Accepted, disclosed residuals (not blocking):
pruneExpiredMutesremains unlocked against the mutex (lower-severity, self-healing);unmuteAccount's revert can still clobber richer Room metadata from a concurrent mute (pre-existing, not introduced here).Gate:
./gradlew testDebugUnitTest lintDebug— 0 failures, 0 lint errors. (2 pre-existing skips inAtProtoOAuthClientTestremain on this branch, same as branch B — resolves once branch A merges first.)Merge order: this is branch C of four (A → B → C → D — DM Phase A work should not proceed until A and C are merged).
Task 7 made ModerationActionsRepository's Room seed async (externalScope.launch instead of runBlocking in init), which removed the implicit guarantee that the seed always completed before any mutation method could run. The seed's own update{} on _mutedDids/_blockedDids is atomic, but the existing mutation methods (muteAccount, unmuteAccount, blockAccount, unblockAccount, pruneExpiredMutes) used non-atomic read-then-write (_x.value = _x.value <op>). A concurrent seed write racing with one of these could silently drop an update. Converts every _mutedDids/_blockedDids mutation to atomic StateFlow.update { it <op> x }. For unmuteAccount/unblockAccount's failure revert paths, this also drops the "restore captured snapshot" pattern in favor of atomically re-adding the DID that was optimistically removed — same intent, but no longer able to clobber a concurrent writer. muteThread/unmuteThread (_mutedThreadUris) and hydrateFromServer's full-set overwrites (_mutedDids.value = mutes / _blockedDids.value = blocks) are out of scope: the former isn't seeded at construction so isn't part of this race, and the latter is an authoritative replace rather than a read-derived write.Review pass 1 — revise
P1 — the async Room seed can republish stale moderation state across mutations and account boundaries. The app-lifetime init coroutine seeds flows outside
hydrationMutex(ModerationActionsRepository.kt:71-80), while hydrate and mutations are mutex-protected. A delayed seed can re-add old mute/block DIDs after hydration, unmute/unblock, or logout cleanup; those flows drive moderation enforcement. Put seed/prune behind the same serialization plus account/session-generation boundary, and add deterministic seed-vs-hydrate/mutation/logout tests.P2 —
pruneExpiredMutes()still bypasses the mutex. It reads/deletes Room and modifies_mutedDidswithouthydrationMutex(:88-99), allowing hydration to republish an expired mute after local expiry removal. Serialize prune with hydration/mutations and test that interleaving.The backup change itself is good:
allowBackup=falseis the appropriate conservative default. Targeted ModerationActionsRepository tests pass locally, but do not cover either race.Closed — merged via direct push
Branch content on main via shared commits. Remaining P1: async Room seeding in ModerationActionsRepository.init launches outside hydration mutex. Follow-up recommended.
Content from
fix/p1-startup-and-backupshipped tomainvia a local--no-ffmerge, which is why Forgejo shows this closed-unmerged rather than merged. Reconciled indocs/reviews/2026-07-25-branch-pr-reconciliation.md, which landed onmainvia #103. Branch deleted as part of that reconciliation.Pull request closed