Fix sync worker stall on persistent errors - #1305
Open
Left024 wants to merge 23 commits into
Open
Conversation
- SyncWorker: cap retry at 3 attempts, then fail to let periodic reset; use accountId from inputData to dispatch correct service type - ReaderWorker: cap retry to avoid blocking POST_SYNC_WORK chain indefinitely - AbstractRssRepository: extract reschedulePeriodicWork() for reuse - AccountViewModel: reschedule periodic tasks immediately after account switch or sync setting change - FeverRssService: use retry instead of failure for consistency
- SyncWorker.enqueuePeriodicWork: use CANCEL_AND_REENQUEUE for ENQUEUED state (only keep UPDATE for RUNNING), so that lastEnqueueTime is reset to current system time on every app start - WidgetUpdateWorker.enqueuePeriodicWork: same treatment - This prevents a one-time clock anomaly from permanently polluting the nextScheduleTimeMillis (symptom: always showing Aug 17 regardless of real date)
- AbstractRssRepository: clearKeepArchivedArticles 接受 accountId 参数;initSync 遍历所有账户独立调度 - SyncWorker: doWork 顶捕异常;POST_SYNC_WORK 传递 accountId;一次性任务 setExpedited;抽取 schedule 构建逻辑 - ReaderWorker: 注入 AccountService;从 inputData 读取 accountId 兼容旧任务回退 - AndroidApp: 注册 NetworkCallback,网络恢复时触发一次性同步 - AndroidManifest: 添加 REQUEST_IGNORE_BATTERY_OPTIMIZATIONS 权限 - NotificationHelper: InboxStyle 展示标题列表;setAutoCancel;确定性通知 ID - ContextExt: 新增电池优化状态检查与设置跳转扩展 - AccountViewModel: 删除账户后重建周期同步;修复局部变量命名歧义 - TroubleshootingPage: 新增电池优化状态卡片;修复 nextScheduledMillis 异常崩溃 - strings: 新增电池优化相关字符串(中英文)
…preset) and open-link FAB - Feed.titleDisplayMode column (0=follow global, 1=always show, 2=always hide) + DB migration 7->8 - DAO bulk update by group; insertOrUpdate preserves local preset on remote sync - Settings: show-title global default & open-link FAB switch (ReadingStylePage), default on - Reading page: hides the big title per preset chain (feed/group > global); list unaffected - Reading page: bottom-right circular FAB opens the original link (same as tapping the title) - Feeds page long-press: per-feed tri-state chips; per-group tri-state apply dialog
Left024
force-pushed
the
fix/sync-retry-stall
branch
from
September 4, 2026 16:22
a0ee50c to
db5e503
Compare
…rogress percentage Read-state (GoogleReader/TTRSS & Fever): - Sync no longer force-marks locally read articles back to unread when the remote snapshot still lists them unread; instead the read state is pushed to the server (idempotent, retried every sync until the server converges). - Fever: article import no longer overwrites existing rows (preserves local read/ starred); notifications only for genuinely new articles; same local-read-wins reconcile with server push. Sync progress: - AbstractRssRepository exposes onSyncProgress; Local/GR/Fever report staged percentages; SyncWorker forwards them via WorkManager setProgress. - Flow pull-to-refresh indicator shows percentage next to the spinner.
…; feeds page percent; fever null guard - Auto read on opening an article now also commits to DB and pushes to the server right away (was diff-overlay only, lost after sync-driven list refresh) - GR sync progress: id-list paging reports (unread/starred/read) for both full account and single feed sync; progress is monotonic per sync (no regression) - Feeds page (home) pull-to-refresh now shows the sync percentage under the spinner via WorkManager progress - Fever reconcile skips read-state changes when the unread list is unavailable (null) instead of flipping locally read articles back to unread
Left024
force-pushed
the
fix/sync-retry-stall
branch
from
September 4, 2026 17:31
869c290 to
843e299
Compare
Replace M3 PullToRefreshBox on the feeds (home) page with the same PullToLoad-based mechanism and PullToSyncIndicator used by the article flow page: pull-down progress circle, and while syncing the pill with spinner plus percentage shown together at the top. Progress percentage stays wired via WorkManager.
…commit Revert the immediate DB commit + server push when opening an article (it made the unread filter drop read articles from the list the moment you went back). Auto-read again only sets the UI diff overlay, so returning from the reader shows the article grayed out in the list; the commit to DB (and later server push via sync reconcile) is deferred to when the whole flow list page is left (FlowPage onDispose), which also keeps the previous sync fix intact: sync no longer flips locally read articles back to unread.
The previous onDispose hook could fire while opening an article in some navigation layouts, committing read diffs too early (article then disappears from the unread filter right after going back). Route both exits from the article list to home through one commit point: the top-bar back arrow and the system back gesture (BackHandler) both call exitToHome, which commits the pending read diffs (DB + server push via next sync) before navigating up. Reading -> back to list still keeps the gray read overlay.
commitDiffsToDb was fire-and-forget and cleared the UI overlay before the DB write finished; the sync worker could then snapshot articles as still unread, so after a refresh articles read before it looked unread again. - DiffMapHolder: new suspend commitDiffsNow() that writes DB first and only then clears the overlay/cache; commitDiffsToDb delegates to it - ArticleListReaderViewModel.sync(): awaits commitDiffsNow() on the IO dispatcher before enqueueing the sync worker, so the sync snapshot (and the local-read->remote push reconcile) sees the latest read state
…ips overlay reads When a refresh runs while the user is reading, the sync reconcile used to mark those articles read in the DB (remote already read because the overlay diff was pushed), which made the unread filter drop them right after the refresh. - DiffMapHolder is now a @singleton shared session state and exposes overlayReadIds() (articles currently shown gray via the UI overlay) - GoogleReader full-account and single-feed reconcile, and Fever reconcile, skip marking DB-read any article that is still in the gray overlay, so the gray state survives the refresh - sync() no longer commits diffs up front; commit to DB stays at leave-list (exitToHome), ON_PAUSE, and cache-restore points as before
DiffMapHolder now receives Provider<RssService> (resolved lazily via currentRssRepository()) instead of the eager RssService instance, breaking the cycle RssService -> LocalRss/Fever/GoogleReaderRssService -> DiffMapHolder -> RssService at the graph level.
GR/Fever/Local sync end with accountService.update(account.copy(updateAt=Date())), which re-emits currentAccountFlow. DiffMapHolder treated that as an account change and ran cleanup(), clearing the gray read overlay and making articles appear unread right after a refresh finished. Only cleanup on real account id change now. Also snapshot diffMap before async cache write to avoid losing diffs during cleanup.
- fetchItemIdsAndContinue pages start at 1 and the old progress formulas used integer division (7*page/20 etc.), so intermediate percentages were swallowed and the UI jumped 5% -> 17% -> 100%. Use fixed +2% per page. - The 90% report used to fire before the new-article content batch finished (and before it even started when there was nothing new), hiding the whole 30-88 content range. Move it after the content job completes; report 45% after feed/group upsert and 60% when there is nothing new to fetch. - PullToSyncIndicator now eases the displayed percent toward the real progress so discrete reports render as a continuous count-up.
…s fallback The refresh stalled around 20% because the longest real work — pushing locally-read ids back to the remote via editTag chunks (and the subscription list fetch) — reported no progress, and everything after it (45/60/90/100) fired within a moment at the end. Report per-chunk progress for the push (40..55 whole-account, 72..80 per-feed), add 38% after the subscription list resolves, and move 90% behind the orphan cleanup. PullToSyncIndicator now crawls the displayed percent up slowly (cap 96) whenever real progress is stalled, so the number never sits frozen mid-sync.
Replace the fixed hard-coded percentage ranges with a dynamic budget planner: every real network round-trip reports a step. Phase 1 (id list pagination + subscription list, whose totals are unknown up front) steps +1% per page capped at 32; when the totals become known (content batches, push chunks, cleanup), the remaining budget is reallocated across those exact work units so 100% lands exactly when the sync truly finishes. Remove the fake slow crawl from PullToSyncIndicator; the displayed percent only eases toward real progress and freezes when the backend stalls.
The account's id lists span 30+ pages but phase 1 was capped at 32%, so the percentage froze at 32% while the remaining id pages were still being fetched (the actual bulk of sync time), then jumped to 100% at the end. 2% per page with a 96% cap keeps the number advancing through the entire id-list phase for typical account sizes.
The indicator was composed inside the Scaffold content box, so it was positioned below the top app bar on the feeds page. Move it to an outer Box over the scaffold so it anchors to the top of the screen exactly like on the article list page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Credits
This change was made by DeepSeek V4 Pro and has been manually tested by @Left024 on device — verified working correctly.
Problem
When background sync encounters an error (e.g., FreshRSS credentials expire, network issues), the SyncWorker enters infinite exponential backoff (capped at 5h). Users experience this as "auto-refresh stopped working completely" — it never recovers until manual refresh or app restart.
Additional issues:
Root Cause
sync()returnsResult.Retry()→ WorkManager exponential backoff. Each retry doubles the wait, permanently suspending the normal sync interval rhythmenqueuePeriodicWorkbakesaccountIdintoinputData, butdoWork()usesrssService.get()(current account type) — cross-account dispatch fails with type checksPOST_SYNC_WORKnever completes → subsequent syncs can't enqueue new ReaderWorker/WidgetUpdateWorkerinitSync()Changes
Result.Failure— for periodic tasks this triggersresetPeriodic(), allowing the next sync interval to proceed normally. Also injectingAccountServiceto dispatch byinputData.accountIdinstead of current accountPOST_SYNC_WORKchain indefinitely; missing full content will be re-fetched by the next sync cyclereschedulePeriodicWork(account)frominitSync()for external reusereschedulePeriodicWorkimmediately on account switch and sync setting changes, no longer requires app restartResult.Failure()toResult.Retry()for behavior consistency with Local/GoogleReaderAdditional Improvements (2026-07-22)
Based on testing and further analysis, these incremental changes enhance sync reliability and user experience:
SyncWorker & AbstractRssRepository
initSync()now iterates all accounts (not just the current one), enqueuing periodic work for each — ensures all accounts get synced after app restartdoWork()wrapped in try-catch to prevent unhandled exceptions from crashing the workerPOST_SYNC_WORKchain passesaccountIdtoReaderWorkerfor correct account dispatchsetExpedited()for faster executionenqueuePeriodicWorkschedule constraints extracted into a reusable builderReaderWorker
AccountServiceinjected to resolve account type frominputData.accountId, with fallback to current account for backward compatibility with old tasksNetwork Recovery (AndroidApp.kt)
NetworkCallbackthat triggers a one-shot sync when network becomes available after a disconnect — prevents missed sync windows when offlineBattery Optimization (AndroidManifest + ContextExt + TroubleshootingPage)
REQUEST_IGNORE_BATTERY_OPTIMIZATIONSpermission to both manifestsisIgnoringBatteryOptimizations()andopenBatteryOptimizationSettings()extension functionsnextScheduledMillis(shows "N/A")Notifications (NotificationHelper.kt)
InboxStyleto display article title listsetAutoCancel(true)for automatic dismissalarticle.id.hashCode()) instead of random numbersAccountViewModel
rssService→rssRepoto disambiguate from member propertyStrings
Files Updated (both commits combined)