From 2acff395a12d968a4c250917c05c7d4eaa569592 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 26 Jun 2026 22:41:02 -0500 Subject: [PATCH] Renew IMAP IDLE periodically to survive idle-socket timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../kotlin/org/libremail/mail/ImapClient.kt | 20 ++++++++++++++----- .../kotlin/org/libremail/push/IdleService.kt | 18 +++++++++++++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 11d36d9..d232967 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -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() } } diff --git a/app/src/main/kotlin/org/libremail/push/IdleService.kt b/app/src/main/kotlin/org/libremail/push/IdleService.kt index 0484581..3c2b9b9 100644 --- a/app/src/main/kotlin/org/libremail/push/IdleService.kt +++ b/app/src/main/kotlin/org/libremail/push/IdleService.kt @@ -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 } }