Renew IMAP IDLE periodically to survive idle-socket timeouts

A single IDLE connection held indefinitely gets dropped by servers after ~29 min
(RFC 2177) and severed by NAT/firewalls sooner — silently stranding push.

- IdleService bounds each IDLE session with withTimeoutOrNull(IDLE_RENEWAL_MS = 9 min)
  and reconnects, re-issuing IDLE well within those limits. Each reconnect catch-up
  syncs, so no mail is missed across renewals.
- ImapClient.idle() now closes the store from an awaitCancellation() child that runs at
  cancellation *start*. A Job completion handler never runs while idle()'s blocking read
  is stuck cancelling, so it could not unblock idle(); this can, so both renewal and
  service stop break out of idle() promptly.
- Verified on the Android 17 emulator against GreenMail: the IDLE connection
  re-established on schedule (each cycle = fresh connect + IDLE), and a message delivered
  mid-run still pushed a notification within ~2s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 22:41:02 -05:00
co-authored by Claude Opus 4.8
parent 92e5005474
commit 2acff395a1
2 changed files with 31 additions and 7 deletions
@@ -17,10 +17,11 @@ import java.util.Properties
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.eclipse.angus.mail.imap.IMAPFolder
@@ -167,8 +168,17 @@ class ImapClient @Inject constructor() {
val syncer = launch {
for (signal in pushes) onActivity()
}
// Closing the store from the cancellation handler unblocks the blocking idle() below.
val handle = coroutineContext.job.invokeOnCompletion { runCatching { store.close() } }
// Close the connection the moment this scope is cancelled (renewal timeout or
// service stop). Doing it here — at cancellation *start*, not job completion —
// unblocks the blocking idle() read below so the loop exits promptly; a
// completion handler would never run while idle() is still blocked.
val closer = launch {
try {
awaitCancellation()
} finally {
withContext(NonCancellable) { runCatching { store.close() } }
}
}
// Sync once on connect to catch anything that arrived before IDLE was established.
pushes.trySend(Unit)
try {
@@ -178,9 +188,9 @@ class ImapClient @Inject constructor() {
} catch (e: Exception) {
if (isActive) throw e // a real connection error: let the caller reconnect
} finally {
handle.dispose()
pushes.close()
closer.cancel()
syncer.cancel()
pushes.close()
runCatching { inbox.close(false) }
runCatching { store.close() }
}
@@ -20,6 +20,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.libremail.R
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.toDomain
@@ -62,13 +63,22 @@ class IdleService : Service() {
accounts.forEach { account -> scope.launch { watchAccount(account) } }
}
/** Holds IDLE for one account, reconnecting with exponential backoff whenever it drops. */
/**
* Holds IDLE for one account, reconnecting with exponential backoff whenever it drops.
* Each IDLE session is bounded by [IDLE_RENEWAL_MS]: when it elapses, [withTimeoutOrNull]
* cancels idle() (which closes the connection to unblock it) and we reconnect with a fresh
* IDLE. This re-issues IDLE well within RFC 2177's 29-minute limit and before NAT/firewall
* idle-socket timeouts would silently strand the connection. Each reconnect catches up via
* idle()'s on-connect sync, so no mail is missed across renewals.
*/
private suspend fun watchAccount(account: Account) {
var backoffMs = INITIAL_BACKOFF_MS
while (scope.isActive) {
try {
val params = connectionFactory.imapParamsFor(account)
imapClient.idle(params) { mailSyncer.syncAll() }
withTimeoutOrNull(IDLE_RENEWAL_MS) {
imapClient.idle(params) { mailSyncer.syncAll() }
}
backoffMs = INITIAL_BACKOFF_MS
} catch (e: CancellationException) {
throw e
@@ -115,5 +125,9 @@ class IdleService : Service() {
const val FOREGROUND_ID = 1002
const val INITIAL_BACKOFF_MS = 5_000L
const val MAX_BACKOFF_MS = 5 * 60_000L
// Re-establish IDLE on this cadence — under RFC 2177's 29-minute ceiling and short enough
// to beat typical NAT/firewall idle-socket timeouts.
const val IDLE_RENEWAL_MS = 9 * 60_000L
}
}