An on-device drilldown proved Gmail server-side throttles LibreMail's connect-per-operation IMAP: every op was a fresh CONNECT+TLS+LOGIN, and full-history backfill's body+attachment prefetch generated ~601 connections in ~22 min, tripping (and sustaining) Gmail's per-account rate/bandwidth clamp (body download collapsed to ~4 KB/s). The `live` gauge peaked at only 5 (Gmail allows ~15), so it is connection *volume*, not count. Outlook IMAP on the same device opened in 2-3 s. Reusing one warm socket per account (~601 -> ~1) removes the throttle's trigger. This wires the reuse path the #125 spike built and left OFF (issue #357 Part 2 — connection reuse only; prefetch is a separate PR). How it is enabled (with a safety switch): - New `BuildConfig.IMAP_CONNECTION_REUSE` (default true) drives the production `ImapClient` no-arg `@Inject` constructor. To disable if a server misbehaves, flip it to "false" in app/build.gradle.kts — a build-config change, no Kotlin edit. The internal `ImapClient(reuseConnections, reuseIdleTimeoutMillis)` constructor stays the test/harness seam. - Universal: applies to all providers (incl. Outlook). No per-provider caps or throttling here — that is a separate effort (#356/#360-#364). Hardening `ImapConnectionCache` for production (was a spike): - Transparent stale recovery: broadened drop detection to Angus's own `iap.ConnectionException` (and a MessagingException caused by one) — the real signal `folder.open()` throws on a server-dropped idle socket, which the IOException-only check missed, so the reconnect now actually fires. A dropped reused socket is rebuilt once and the op retried, so callers see no spurious error; a genuine app error (e.g. message-not-found) is never retried. - Idle eviction: `evictIdle()` closes a connection unused past the reuse idle timeout (default 5 min), swept every 2 min by `IdleService`; skips any in-use connection. - Teardown: `IdleService` also tears down reused connections on the low-battery push-teardown path (#88/#89/#90), mirroring the IDLE connection teardown. - Concurrency: one connection per account behind a per-account mutex; the eviction sweep takes the lock non-blockingly so it never stalls or interrupts an in-flight op. Coexists with IMAP IDLE (its own separate connection). - PII-free AppLog on the lifecycle (open / reuse-hit / reconnect-stale / evict / teardown) keyed by an opaque per-cache ordinal, plus the #358 ImapPerf breadcrumb (connect~=0ms on a reuse hit). Tests (all via the fast gate, no emulator): - ImapConnectionCacheTest: reuse, retry-once stale recovery, narrow drop detection, deterministic idle eviction (injected clock), teardown. - ImapFolderOpenLatencyTest (GreenMail + counting proxy): N ops share one connection/LOGIN; a force-dropped socket is transparently reconnected; an app error does not reconnect; idle eviction LOGS-OUT and the next op reconnects. - Correctness suites (ImapClientTest/ImapClientBackfillTest/MailBackfillerTest) pinned to reuse-off to keep their connect-per-op assertions unchanged. Fast gate green: assembleDebug, testDebugUnitTest, compileDebugAndroidTestKotlin, lintDebug, ktlintCheck, detekt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
IMAP connection-reuse spike (issue #125)
Update — shipped (issue #357 Part 2). The real-device validation this spike deferred has since run: an on-device drilldown proved Gmail server-side throttles LibreMail's connect-per-operation IMAP (full-history backfill generated ~601 connections in ~22 min, tripping and sustaining a per-account rate/bandwidth clamp;
livepeaked at only 5, so it is connection volume, not count). Connection reuse is therefore now ON by default, gated byBuildConfig.IMAP_CONNECTION_REUSEas a safety switch, with the cache hardened for production: transparent stale-connection recovery, idle eviction (ImapConnectionCache.evictIdle, swept byIdleService), low-battery teardown, and per-account mutex concurrency. The single-connection-vs-pool and per-provider-cap knobs below remain a separate effort (#356/#360-#364); this change is connection reuse only. The sections below are the original spike design, kept for context.
A time-boxed spike that prototypes the connection reuse the investigation
(issue-125-imap-folder-open.md) recommended and defers. It exists to reduce uncertainty — is
per-account keep-alive feasible in this codebase, and does it actually collapse the per-open setup
cost? — not to ship a finished feature. The prototype is flag-gated and OFF by default, so it
cannot change main's behaviour, and the win is proven structurally with the existing GreenMail
harness.
Still no wall-clock numbers. As in the investigation, everything here counts protocol round-trips (deterministic in-process) and TCP connections. Localhost GreenMail is ~0 RTT, so this spike proves the connection is reused (structure), not how many milliseconds that saves (that is the real-device work in the last section). No latency figure is fabricated.
What the spike delivers
- A flag-gated per-account keep-alive prototype —
ImapConnectionCache+ an OFF-by-defaultreuseConnectionsflag onImapClient. - Deterministic proof it reuses the connection — two new
ImapFolderOpenLatencyTestcases that flip the flag on and assert the connection/LOGIN counts collapse, run against real in-process IMAP. - This design note: the prototype's stance on each real design decision, and the refined real-device validation plan.
The prototype
The flag (default OFF, cannot destabilize main)
ImapClient's production constructor is unchanged in behaviour:
class ImapClient(private val reuseConnections: Boolean) {
@Inject constructor() : this(reuseConnections = false) // production: reuse OFF
...
}
Hilt still calls the no-arg @Inject constructor, so every production/ImapClient() call site gets
reuseConnections = false. With the flag off, withStore is byte-for-byte the previous
connect-per-call + LOGOUT-per-call code, the reuse cache is never allocated, and no new state or
code path is reachable. Only the harness opts in, via ImapClient(reuseConnections = true). When
real-device validation confirms the win, this flag is what gets wired to a setting / BuildConfig.
The reused connection — ImapConnectionCache
ImapConnectionCache keeps one authenticated jakarta.mail.Store alive per account and lends it out:
- One connection per account, mutex-guarded. Each account key owns a single
Storebehind its own coroutineMutex;withStorelocks it, ensures theStoreis connected (creating it on first use), runs the operation, and returns without closing it. Angus'sIMAPStoreinternally pools the authenticated connection across folderopen()/close(), so a kept-aliveStorereuses one socket; the reused store is pinned toconnectionpoolsize=1+separatestoreconnection=falseso it is provably a single socket. - Keyed by connection identity, not the secret. The key is
host|port|security|username|useXoauth2— deliberately excludingsecret, so a rotated OAuth access token reuses the same live, already-authenticated socket instead of orphaning it. The currentparams(with the fresh secret) is always passed toconnect, so a genuine reconnect uses the new token. - Lazy, catch-and-retry-once stale handling. No periodic
NOOPprobe (that would add a round-trip to every reused op, partly defeating the point). An operation runs optimistically; if it throws a dropped-connection signal (FolderClosedException,StoreClosedException, or aMessagingExceptioncaused byIOException), the socket is rebuilt once and the op retried. A non-connection error (e.g. "message not found") is never retried. closeReusedConnections()evicts everything (LOGOUT+ teardown). Today it is the only eviction, driven by the harness; a shipped feature would also drive it from an idle timer and the low-battery push teardown.
IDLE is untouched: ImapClient.idle still opens its own dedicated long-lived Store (it is not in
the cache), so the reuse connection is strictly additional to the IDLE connection — which is
exactly why the per-account connection budget below is a first-class concern.
Deterministic proof (the harness, flag off vs on)
ImapFolderOpenLatencyTest routes ImapClient through CountingImapProxy (a localhost TCP proxy in
front of GreenMail that counts TCP connections and parses IMAP command words). The existing cases pin
the flag-off behaviour; the two new cases flip the flag on over the same real IMAP
operations. For N = OPENS = 3 folder-opens:
| Scenario | TCP connections | LOGIN | EXAMINE (per open) | LOGOUT |
|---|---|---|---|---|
Flag OFF — N folder-opens |
N (=3) |
N (=3) |
N (=3) |
N (=3) |
Flag ON — N folder-opens |
1 | 1 | N (=3) |
1 (at eviction) |
| Flag OFF — open folder + read a message | 2 | 2 | (1 EXAMINE + 1 SELECT) | 2 |
| Flag ON — open folder + read a message | 1 | 1 | (1 EXAMINE + 1 SELECT) | 1 |
The avoidable setup — CONNECT + TLS + LOGIN and the trailing LOGOUT — drops from once per
operation to once per account, ever, while the intrinsic per-folder EXAMINE is unchanged. That
divergence (operations ≫ connections/LOGINs) is connection reuse, proven against a real IMAP
server. These flag-on assertions are also the regression guard the investigation asked for: they fail
if reuse ever silently regresses to connect-per-call.
All six cases pass on the JVM fast gate (:app:testDebugUnitTest); no emulator needed.
Real design decisions — the prototype's stance and the trade-offs
The spike takes the simplest defensible position on each knob and leaves the tuning to measurement. Each is a genuine latency/battery/complexity trade-off that localhost cannot settle.
| Decision | Prototype's stance | Trade-off / what's left open |
|---|---|---|
| Single connection vs. bounded pool | Single mutex-guarded connection per account. | Simplest and provably one socket, but head-of-line blocking: a quick flag toggle can queue behind a slow body download — a regression of today's connect-per-call concurrency. A bounded pool (N sockets + a size cap) restores parallelism at the cost of more sockets and eviction bookkeeping. Which wins needs real throughput/latency measurement. |
| Idle-eviction timeout | None yet; a connection lives until closeReusedConnections(). |
A kept-alive socket has a battery cost (below). The right idle timeout is a battery-vs-latency trade-off; the hook exists (closeReusedConnections) but no timer drives it. |
| Stale-connection detection | Lazy catch-and-retry-once on a dropped-connection signal; no NOOP probe. |
Retry avoids a per-op probe RTT but means one operation fails then recovers when a stale socket is first used; a NOOP pre-check trades that for a guaranteed extra RTT on every op. For a mutating op, an automatic retry after a mid-flight drop is at-least-once — safe for the read-only folder-open target, but a real-server correctness item for flags/move/expunge. |
| IDLE per-account budget (#90) | Reuse connection is additional to the IDLE connection (IDLE stays separate). | So an account holding IDLE and a reuse connection uses ≥2 persistent sockets; a bounded pool would use even more. Must stay under the server's per-account limit (Gmail ~15; many servers 3–5). A shipped version should treat IDLE + reuse (+ pool) as one budget. |
Concurrency (prefetch outside syncMutex, unserialized UI ops) |
The per-account mutex serializes all reuse traffic for an account. | Correct and thread-safe under the current design (concurrent UI ops + prefetch can hit the same account), but it serializes work that today runs concurrently on separate throwaway sockets — the head-of-line cost again. A pool would relax this. |
| Low-battery posture (#88/#89/#90) | None yet — no battery signal wired in. | A kept-alive socket has idle cost; #90 already tears IDLE down at low battery. Reuse should mirror that (evict + stop reusing at low battery). The eviction hook exists; the policy wiring is deferred. |
Deliberately left open (out of this spike's scope): the eviction timer, the battery-signal wiring, the bounded-pool variant, unifying the IDLE + reuse connection budget, and the mutation-retry idempotency review. Each needs the real-device measurement below to tune, not a guess.
Feasibility verdict + recommendation
Feasible, and mechanically small. The reuse path is one ~90-line class plus a flag; the existing
concurrency model already hands us the seam (a single withStore chokepoint every operation flows
through), and Angus's own connection pooling does the socket reuse once we stop discarding the Store.
The structural win is real and now proven: setup collapses from per-operation to per-account.
Recommendation: keep the flag OFF and land this as a spike (harness + prototype + this note). Before flipping the default on, do the real-device validation below and decide the two knobs that localhost cannot: single connection vs. bounded pool (measure the head-of-line cost against real concurrent UI-op + prefetch traffic) and the idle-eviction timeout (measure the kept-alive socket's battery cost). Ship the mutation-retry idempotency review and the IDLE-budget unification alongside. If the pool is chosen, the mutex-per-account seam generalizes to a bounded semaphore with minimal churn.
Real-device / real-account validation that remains
Refines the investigation's six-step plan against what this prototype needs:
- A/B the flag on real accounts/networks. Flip
reuseConnectionson (wire it to a debug setting) and measure folder-switch (open A → open B → back to A) and list-then-open-message latency, cold vs. warm-reuse, on Gmail + Outlook over Wi-Fi and cellular. Expect warm opens to fall by the connection-setup share; quantify it. - Attribute the wall-clock. Instrument
store.connect/open/fetch/close(or Angusmail.imapdebug) and confirm setup dominates the cold open and is what reuse removes. - Decide single vs. pool. Under real concurrent traffic (UI op + prefetch on one account), measure the single-connection head-of-line delay; if material, prototype the bounded pool and re-measure.
- Tune idle-eviction against battery. Measure the kept-alive socket's idle drain across candidate
timeouts; pick one that beats the #88/#89/#90 posture, and wire
closeReusedConnections()to that timer and to the low-battery teardown. - Resilience + connection budget. Force server idle-timeout and network transitions; confirm the catch-and-retry-once reconnect is transparent (and review mutation idempotency), and that IDLE + reuse (+ pool) stay under the per-account connection limit.
- Lock it in. The flag-on
ImapFolderOpenLatencyTestcases are already the deterministic regression guard; once the default flips on, they assert reuse can't silently regress.