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 } }