fix(push): IdleService dataSync FGS crash-loop — START_STICKY restart into exhausted 24h cap throws ForegroundServiceStartNotAllowedException #354

Closed
opened 2026-07-05 20:54:14 +00:00 by JMR-dev · 0 comments
JMR-dev commented 2026-07-05 20:54:14 +00:00 (Migrated from github.com)

Summary

Recurring on-device crash loop captured from the maintainer's Pixel 10 Pro XL (Android 17 / SDK 37). After the dataSync foreground-service runtime cap from #302 fires and IdleService self-stops, the service is restarted — both by START_STICKY null-intent redelivery and by explicit startForegroundService calls — and onStartCommand unconditionally calls startForeground(..., FOREGROUND_SERVICE_TYPE_DATA_SYNC) while the rolling-24h dataSync budget is still exhausted. The platform rejects that start with ForegroundServiceStartNotAllowedException: Time limit already exhausted for foreground service type dataSync; the exception is uncaught, the process crashes, and START_STICKY restarts it straight back into the same rejection → crash loop until the 24h window frees budget.

This is a direct follow-up to #302: #302 added the onTimeout → fallBackToPeriodicSync() clean stop, but nothing prevents the subsequent (re)start from attempting a now-illegal dataSync FGS start.

Evidence

Artifacts: 2 in-app DebugReports + dumpsys dropbox (10 entries) + a full system bugreport, all from the maintainer's device. Times are device-local (-0500).

Signature C — ForegroundServiceStartNotAllowedException: Time limit already exhausted for foreground service type dataSync at IdleService.startAsForeground ← IdleService.onStartCommand (:87), wrapped in java.lang.RuntimeException: Unable to start service ...IdleService.... 5 occurrences:

  • 2026-07-04 04:24:08.821 — redelivered null intent (START_STICKY)
  • 2026-07-04 05:53:09.878 — null intent
  • 2026-07-04 06:00:01.695 — null intent
  • 2026-07-04 23:42:53.704 — explicit Intent (startForegroundService) — DebugReport 28d3fa45
  • 2026-07-04 23:42:54.921 — explicit Intent — DebugReport 2c1fb3cc

Signature B — RemoteServiceException$ForegroundServiceDidNotStopInTimeException: A foreground service of type dataSync did not stop within its timeout ...IdleService. 1 occurrence:

  • 2026-07-04 04:24:08.039 — on a pre-#302 build (stack startAsForeground line 206). Mitigated on current main by the onTimeout handler; listed for completeness so the fix keeps the stop path within the grace window.

Smoking gun — DebugReport 28d3fa45 in-app log buffer:

04:42:53.582Z  I/IdleService: dataSync FGS runtime cap reached: pausing IMAP IDLE; mail arrives via 15-minute periodic sync
04:42:53.672Z  E/CrashReporter: Uncaught exception: java.lang.RuntimeException   (+90 ms)

i.e. within 90 ms of fallBackToPeriodicSync() stopping the FGS, a restart re-enters onStartCommand → startForeground → rejection → crash.

Device/build: Google Pixel 10 Pro XL, Android release 17, SDK 37; app 0.1.0 (versionCode 1); Gmail PASSWORD_IMAP; settings pushIdle=true, encryptCache=false. No org.libremail tombstones or ANR traces in the bugreport (crashes are pure Java RuntimeExceptions).

Separate cluster, not this ticket: 4× java.lang.UnsatisfiedLinkError ... SQLiteConnection.nativeOpen on 2026-07-03 09:02:22–57 (SQLCipher native lib not loaded when opening the encrypted cache). Flagged for its own ticket.

Root cause — app/src/main/kotlin/org/libremail/push/IdleService.kt (origin/main)

  • onStartCommand (line 86) calls startAsForeground(shownMode) unconditionally (line 87) and returns START_STICKY (line 101).
  • startAsForeground (line 231) calls ServiceCompat.startForeground(..., FOREGROUND_SERVICE_TYPE_DATA_SYNC) (lines 233–237) with no guard / try-catch.
  • When #302's cap handler fallBackToPeriodicSync() (line 171) stops the service, START_STICKY makes the platform recreate it with a null intent, and IdlePushManager.start() / LibreMailApplication.ensurePushStarted() also re-issue explicit startForegroundService calls (e.g. on MainActivity foreground). Each lands in onStartCommand → startForeground(dataSync) while the 24h budget is exhausted → ForegroundServiceStartNotAllowedException (uncaught) → crash; START_STICKY re-restarts → loop.
  • IdlePushManager.start() wraps its startForegroundService in runCatching, but that only guards the caller; the throwing startForeground runs later on the service's own main thread and is not covered by it.

Proposed fix (IdleService.kt)

  1. Return START_NOT_STICKY from onStartCommand instead of START_STICKY. Push is app-managed — LibreMailApplication's settings/account collector and ensurePushStarted() deterministically (re)start the service whenever it should run — so the platform's sticky null-intent auto-restart is redundant and fires exactly in the states that can't legally start a dataSync FGS (background / exhausted budget). Removes the null-intent restart loop.
  2. Guard the FGS start. Wrap startForeground in try/catch for ForegroundServiceStartNotAllowedException (API 31+; minSdk is 29, so gate with Build.VERSION.SDK_INT >= 31 or catch its IllegalStateException supertype). On rejection, do not rethrow — degrade exactly like the cap handler: syncScheduler.schedulePeriodicSync(), post/keep the degraded PushMode.POLLING notification, then stopSelf(). (The start arrived via startForegroundService, so we must promptly stopSelf() after a failed startForeground to avoid the "did not call startForeground in time" ANR.) Return START_NOT_STICKY on this path.
  3. Optional hardening: record a timestamp when fallBackToPeriodicSync() runs and, while still within the cap window, have onStartCommand skip the startForeground attempt entirely (schedule periodic sync + stopSelf()), so we don't rely on catching the exception on every restart.
  4. Keep the #302 onTimeout stop path synchronous/fast so ForegroundServiceDidNotStopInTimeException stays mitigated.

Logging (PII-free AppLog, per DoD)

  • On caught rejection: AppLog.w(TAG, "dataSync FGS start rejected (runtime cap or background); staying on 15-minute periodic sync") — no account/email/intent contents.
  • If adding the cap-window skip: AppLog.i(TAG, "dataSync FGS cap still active; skipping foreground start, periodic sync covers mail").

Affected files / lines (origin/main)

  • app/src/main/kotlin/org/libremail/push/IdleService.kt: onStartCommand :86–:102 (start :87, return START_STICKY :101), startAsForeground :231–:238 (startForeground :233, DATA_SYNC type :237), cap handler fallBackToPeriodicSync :171–:188.
  • Context (no change required, but explains the restart triggers): app/src/main/kotlin/org/libremail/push/IdlePushManager.kt (start() runCatching only guards the caller), app/src/main/kotlin/org/libremail/LibreMailApplication.kt (:87–:108, the start() / ensurePushStarted() triggers).

Definition of done

  • Unit test (JVM/Robolectric): onStartCommand returns START_NOT_STICKY; a startForeground that throws ForegroundServiceStartNotAllowedException is caught → schedulePeriodicSync() invoked + stopSelf() + no exception propagates.
  • E2E/instrumented test: the degrade path (FGS start rejected → service stops cleanly, periodic-sync fallback scheduled, degraded notification shown), alongside the existing PushStatusNotificationInstrumentedTest.

Suggested priority: P1 — recurring crash loop on the target/preview top SDK (37) on a daily-driver device; it self-heals when the 24h window resets and periodic sync still delivers mail, so not P0. Coordinator finalizes.

## Summary Recurring on-device crash **loop** captured from the maintainer's Pixel 10 Pro XL (Android 17 / SDK 37). After the `dataSync` foreground-service runtime cap from #302 fires and `IdleService` self-stops, the service is restarted — both by `START_STICKY` null-intent redelivery and by explicit `startForegroundService` calls — and `onStartCommand` unconditionally calls `startForeground(..., FOREGROUND_SERVICE_TYPE_DATA_SYNC)` while the rolling-24h dataSync budget is still exhausted. The platform rejects that start with `ForegroundServiceStartNotAllowedException: Time limit already exhausted for foreground service type dataSync`; the exception is uncaught, the process crashes, and `START_STICKY` restarts it straight back into the same rejection → crash loop until the 24h window frees budget. This is a direct **follow-up to #302**: #302 added the `onTimeout` → `fallBackToPeriodicSync()` clean stop, but nothing prevents the *subsequent (re)start* from attempting a now-illegal dataSync FGS start. ## Evidence Artifacts: 2 in-app `DebugReport`s + `dumpsys dropbox` (10 entries) + a full system bugreport, all from the maintainer's device. Times are device-local (-0500). **Signature C — `ForegroundServiceStartNotAllowedException: Time limit already exhausted for foreground service type dataSync`** at `IdleService.startAsForeground` ← `IdleService.onStartCommand` (`:87`), wrapped in `java.lang.RuntimeException: Unable to start service ...IdleService...`. 5 occurrences: - 2026-07-04 04:24:08.821 — redelivered **null** intent (`START_STICKY`) - 2026-07-04 05:53:09.878 — null intent - 2026-07-04 06:00:01.695 — null intent - 2026-07-04 23:42:53.704 — explicit **Intent** (`startForegroundService`) — DebugReport `28d3fa45` - 2026-07-04 23:42:54.921 — explicit Intent — DebugReport `2c1fb3cc` **Signature B — `RemoteServiceException$ForegroundServiceDidNotStopInTimeException: A foreground service of type dataSync did not stop within its timeout ...IdleService`.** 1 occurrence: - 2026-07-04 04:24:08.039 — on a pre-#302 build (stack `startAsForeground` line 206). Mitigated on current `main` by the `onTimeout` handler; listed for completeness so the fix keeps the stop path within the grace window. **Smoking gun** — DebugReport `28d3fa45` in-app log buffer: ``` 04:42:53.582Z I/IdleService: dataSync FGS runtime cap reached: pausing IMAP IDLE; mail arrives via 15-minute periodic sync 04:42:53.672Z E/CrashReporter: Uncaught exception: java.lang.RuntimeException (+90 ms) ``` i.e. within 90 ms of `fallBackToPeriodicSync()` stopping the FGS, a restart re-enters `onStartCommand` → `startForeground` → rejection → crash. Device/build: Google Pixel 10 Pro XL, Android release 17, SDK 37; app 0.1.0 (versionCode 1); Gmail `PASSWORD_IMAP`; settings `pushIdle=true`, `encryptCache=false`. No org.libremail tombstones or ANR traces in the bugreport (crashes are pure Java `RuntimeException`s). > Separate cluster, **not** this ticket: 4× `java.lang.UnsatisfiedLinkError ... SQLiteConnection.nativeOpen` on 2026-07-03 09:02:22–57 (SQLCipher native lib not loaded when opening the encrypted cache). Flagged for its own ticket. ## Root cause — `app/src/main/kotlin/org/libremail/push/IdleService.kt` (origin/main) - `onStartCommand` (line 86) calls `startAsForeground(shownMode)` unconditionally (line 87) and returns `START_STICKY` (line 101). - `startAsForeground` (line 231) calls `ServiceCompat.startForeground(..., FOREGROUND_SERVICE_TYPE_DATA_SYNC)` (lines 233–237) with no guard / try-catch. - When #302's cap handler `fallBackToPeriodicSync()` (line 171) stops the service, `START_STICKY` makes the platform recreate it with a null intent, and `IdlePushManager.start()` / `LibreMailApplication.ensurePushStarted()` also re-issue explicit `startForegroundService` calls (e.g. on MainActivity foreground). Each lands in `onStartCommand` → `startForeground(dataSync)` while the 24h budget is exhausted → `ForegroundServiceStartNotAllowedException` (uncaught) → crash; `START_STICKY` re-restarts → loop. - `IdlePushManager.start()` wraps its `startForegroundService` in `runCatching`, but that only guards the **caller**; the throwing `startForeground` runs later on the service's own main thread and is not covered by it. ## Proposed fix (`IdleService.kt`) 1. **Return `START_NOT_STICKY`** from `onStartCommand` instead of `START_STICKY`. Push is app-managed — `LibreMailApplication`'s settings/account collector and `ensurePushStarted()` deterministically (re)start the service whenever it should run — so the platform's sticky null-intent auto-restart is redundant and fires exactly in the states that can't legally start a dataSync FGS (background / exhausted budget). Removes the null-intent restart loop. 2. **Guard the FGS start.** Wrap `startForeground` in try/catch for `ForegroundServiceStartNotAllowedException` (API 31+; `minSdk` is 29, so gate with `Build.VERSION.SDK_INT >= 31` or catch its `IllegalStateException` supertype). On rejection, do **not** rethrow — degrade exactly like the cap handler: `syncScheduler.schedulePeriodicSync()`, post/keep the degraded `PushMode.POLLING` notification, then `stopSelf()`. (The start arrived via `startForegroundService`, so we must promptly `stopSelf()` after a failed `startForeground` to avoid the "did not call startForeground in time" ANR.) Return `START_NOT_STICKY` on this path. 3. **Optional hardening:** record a timestamp when `fallBackToPeriodicSync()` runs and, while still within the cap window, have `onStartCommand` skip the `startForeground` attempt entirely (schedule periodic sync + `stopSelf()`), so we don't rely on catching the exception on every restart. 4. Keep the #302 `onTimeout` stop path synchronous/fast so `ForegroundServiceDidNotStopInTimeException` stays mitigated. ## Logging (PII-free `AppLog`, per DoD) - On caught rejection: `AppLog.w(TAG, "dataSync FGS start rejected (runtime cap or background); staying on 15-minute periodic sync")` — no account/email/intent contents. - If adding the cap-window skip: `AppLog.i(TAG, "dataSync FGS cap still active; skipping foreground start, periodic sync covers mail")`. ## Affected files / lines (origin/main) - `app/src/main/kotlin/org/libremail/push/IdleService.kt`: `onStartCommand` `:86`–`:102` (start `:87`, `return START_STICKY` `:101`), `startAsForeground` `:231`–`:238` (`startForeground` `:233`, DATA_SYNC type `:237`), cap handler `fallBackToPeriodicSync` `:171`–`:188`. - Context (no change required, but explains the restart triggers): `app/src/main/kotlin/org/libremail/push/IdlePushManager.kt` (`start()` `runCatching` only guards the caller), `app/src/main/kotlin/org/libremail/LibreMailApplication.kt` (`:87`–`:108`, the `start()` / `ensurePushStarted()` triggers). ## Definition of done - **Unit test** (JVM/Robolectric): `onStartCommand` returns `START_NOT_STICKY`; a `startForeground` that throws `ForegroundServiceStartNotAllowedException` is caught → `schedulePeriodicSync()` invoked + `stopSelf()` + no exception propagates. - **E2E/instrumented test**: the degrade path (FGS start rejected → service stops cleanly, periodic-sync fallback scheduled, degraded notification shown), alongside the existing `PushStatusNotificationInstrumentedTest`. **Suggested priority: P1** — recurring crash loop on the target/preview top SDK (37) on a daily-driver device; it self-heals when the 24h window resets and periodic sync still delivers mail, so not P0. Coordinator finalizes.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#354