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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user