From b0bb942a0240774fdea5f62ee15d7fabede3a388 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 2 Jul 2026 08:24:10 -0500 Subject: [PATCH] test(imap): measure folder-open round-trip structure (#125) Investigate IMAP folder-open latency (follow-up to #86). Localhost GreenMail has ~0 RTT, so real wall-clock latency can't be measured here; instead this pins the folder-open round-trip STRUCTURE deterministically. Finding: ImapClient.withStore wraps every operation in its own short-lived Store, so each folder-open pays a full CONNECT + TLS + LOGIN + EXAMINE + FETCH + LOGOUT. Only EXAMINE + FETCH is intrinsic to opening a folder; the whole connection-setup group is avoidable on the 2nd+ operation if a connection were reused. Optimistic render-from-cache already exists (selectFolder renders cached rows; the network sync is a background refresh). Adds: - CountingImapProxy: a localhost TCP proxy that forwards a cleartext IMAP session to GreenMail while counting TCP connections and parsing IMAP command words. - ImapFolderOpenLatencyTest: asserts the current no-reuse behaviour (N opens => N connections and N LOGINs; list+read => 2 connections) against a real in-process IMAP server. Doubles as the harness to validate a future connection-reuse fix (flip the counts to assert reuse). - docs/perf/issue-125-imap-folder-open.md: the per-open round-trip sequence, avoidable vs. necessary round-trips, and the recommended per-account connection-reuse/keep-alive mitigation with its IDLE / thread-safety / battery / stale-connection constraints. Analysis + harness only; the connection-reuse fix is deferred pending real-network + real-device measurement (see the doc's measurement plan), so this references #125 without closing it. Co-Authored-By: Claude Fable 5 --- .../org/libremail/mail/CountingImapProxy.kt | 163 ++++++++++++++++ .../mail/ImapFolderOpenLatencyTest.kt | 125 +++++++++++++ docs/perf/issue-125-imap-folder-open.md | 175 ++++++++++++++++++ 3 files changed, 463 insertions(+) create mode 100644 app/src/test/kotlin/org/libremail/mail/CountingImapProxy.kt create mode 100644 app/src/test/kotlin/org/libremail/mail/ImapFolderOpenLatencyTest.kt create mode 100644 docs/perf/issue-125-imap-folder-open.md diff --git a/app/src/test/kotlin/org/libremail/mail/CountingImapProxy.kt b/app/src/test/kotlin/org/libremail/mail/CountingImapProxy.kt new file mode 100644 index 0000000..145754e --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/CountingImapProxy.kt @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import java.io.IOException +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +/** + * A tiny localhost TCP proxy that forwards a **cleartext** IMAP session to a real backend (GreenMail) + * while COUNTING what crosses it, so tests can measure the folder-open round-trip *structure* + * deterministically without a real network (issue #125): + * + * - [connectionCount] — how many separate TCP connections the client established. On a real network + * each new connection is a full CONNECT + TLS handshake + LOGIN/AUTH handshake group (several + * RTTs). [ImapClient] opens one [jakarta.mail.Store] — and therefore one connection — per + * operation today, so this equals the number of operations. Connection reuse / pooling would make + * it diverge (many operations, few connections); that divergence is exactly what a future fix + * should produce and what these tests are wired to detect. + * - [commandCount] — how many times each IMAP command word (LOGIN, EXAMINE, SELECT, FETCH, LOGOUT…) + * the client issued, parsed from the cleartext client → server stream. + * + * Point [ImapClient] at [port] instead of the backend's port. Cleartext only (MailSecurity.NONE): + * command parsing needs to see the bytes. Connection counting alone would work through TLS too, but + * the LibreMail unit tests already exercise the plaintext path, matching the existing GreenMail tests. + */ +class CountingImapProxy(private val backendHost: String, private val backendPort: Int) : AutoCloseable { + + private val server = ServerSocket(0, BACKLOG, InetAddress.getByName("127.0.0.1")) + private val connections = AtomicInteger(0) + private val commands = ConcurrentHashMap() + + /** Client → server pump threads, tracked so tests can wait for the parsed command stream to settle. */ + private val clientPumps = Collections.synchronizedList(mutableListOf()) + + @Volatile private var running = true + + /** The local port to point [ImapClient] at; it forwards to the backend. */ + val port: Int get() = server.localPort + + /** Total TCP connections the client has opened through the proxy. */ + val connectionCount: Int get() = connections.get() + + /** How many times the client issued [command] (case-insensitive), e.g. "LOGIN", "EXAMINE". */ + fun commandCount(command: String): Int = commands[command.uppercase()]?.get() ?: 0 + + /** Authentication round-trips: the `LOGIN` command plus any SASL `AUTHENTICATE` (e.g. XOAUTH2). */ + fun authCommandCount(): Int = commandCount("LOGIN") + commandCount("AUTHENTICATE") + + init { + Thread({ acceptLoop() }, "imap-proxy-accept").apply { isDaemon = true }.start() + } + + /** + * Joins the client → server pump threads so every command line sent before each connection closed + * has been parsed. [ImapClient] closes its store (and thus the socket) when an operation finishes, + * which ends the corresponding pump; call this before asserting on [commandCount]. [connectionCount] + * needs no settling — it is incremented synchronously as each connection is accepted. + */ + fun awaitClientStreamsSettled(timeoutMs: Long = SETTLE_TIMEOUT_MS) { + val deadline = System.currentTimeMillis() + timeoutMs + val snapshot = synchronized(clientPumps) { clientPumps.toList() } + for (thread in snapshot) { + val remaining = deadline - System.currentTimeMillis() + if (remaining > 0) thread.join(remaining) + } + } + + override fun close() { + running = false + runCatching { server.close() } + } + + private fun acceptLoop() { + while (running) { + val client = try { + server.accept() + } catch (_: IOException) { + return // server socket closed by close() + } + connections.incrementAndGet() + val backend = try { + Socket(backendHost, backendPort) + } catch (_: IOException) { + runCatching { client.close() } + continue + } + val upstream = Thread({ pumpCountingCommands(client, backend) }, "imap-proxy-up").apply { isDaemon = true } + val downstream = Thread({ pump(backend, client) }, "imap-proxy-down").apply { isDaemon = true } + clientPumps.add(upstream) + upstream.start() + downstream.start() + } + } + + /** Forwards client → server bytes verbatim while parsing each CRLF-terminated line as a command. */ + private fun pumpCountingCommands(from: Socket, to: Socket) { + val buffer = ByteArray(BUFFER_SIZE) + val line = StringBuilder() + try { + val input = from.getInputStream() + val output = to.getOutputStream() + while (true) { + val read = input.read(buffer) + if (read < 0) break + output.write(buffer, 0, read) + output.flush() + for (i in 0 until read) { + when (val ch = buffer[i].toInt().toChar()) { + '\n' -> { + recordCommand(line.toString()) + line.setLength(0) + } + '\r' -> Unit + else -> line.append(ch) + } + } + } + } catch (_: IOException) { + // Peer closed; fall through to socket cleanup. + } finally { + runCatching { from.close() } + runCatching { to.close() } + } + } + + /** Forwards server → client bytes verbatim (no parsing needed for this direction). */ + private fun pump(from: Socket, to: Socket) { + val buffer = ByteArray(BUFFER_SIZE) + try { + val input = from.getInputStream() + val output = to.getOutputStream() + while (true) { + val read = input.read(buffer) + if (read < 0) break + output.write(buffer, 0, read) + output.flush() + } + } catch (_: IOException) { + // Peer closed; fall through to socket cleanup. + } finally { + runCatching { from.close() } + runCatching { to.close() } + } + } + + /** Records the command word of an IMAP line shaped ` [args]`. */ + private fun recordCommand(rawLine: String) { + val parts = rawLine.trim().split(' ', limit = 3) + if (parts.size < 2) return + val command = parts[1].uppercase() + commands.computeIfAbsent(command) { AtomicInteger(0) }.incrementAndGet() + } + + private companion object { + const val BACKLOG = 50 + const val BUFFER_SIZE = 8192 + const val SETTLE_TIMEOUT_MS = 2_000L + } +} diff --git a/app/src/test/kotlin/org/libremail/mail/ImapFolderOpenLatencyTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapFolderOpenLatencyTest.kt new file mode 100644 index 0000000..62c5dd1 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/ImapFolderOpenLatencyTest.kt @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import com.icegreen.greenmail.util.GreenMail +import com.icegreen.greenmail.util.GreenMailUtil +import com.icegreen.greenmail.util.ServerSetupTest +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.domain.model.ImapConnectionParams +import org.libremail.domain.model.MailSecurity +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Measures the folder-open round-trip *structure* over a real in-process IMAP server (issue #125), + * deterministically and without a real network, by routing [ImapClient] through a [CountingImapProxy] + * that counts the TCP connections and IMAP commands it establishes. + * + * The finding these tests pin down: [ImapClient] wraps every operation in its own short-lived + * [jakarta.mail.Store] (`withStore`), so **each folder-open pays a fresh CONNECT + LOGIN + SELECT + + * FETCH + LOGOUT** — nothing is reused between operations. On a real network the CONNECT + TLS + LOGIN + * group is several RTTs of user-perceived latency that a pooled/kept-alive connection would pay only + * once. See `docs/perf/issue-125-imap-folder-open.md`. + * + * These assertions encode the *current* (no-reuse) behaviour. They are also the harness to validate a + * future connection-reuse fix: when the client reuses one authenticated connection across folder + * switches, the connection/auth counts here drop below the operation count — flip the expectations to + * assert reuse and the tests confirm the win against a real IMAP server. + */ +class ImapFolderOpenLatencyTest { + + private lateinit var greenMail: GreenMail + private lateinit var proxy: CountingImapProxy + private val client = ImapClient() + + @Before + fun setUp() { + greenMail = GreenMail(ServerSetupTest.SMTP_IMAP) + greenMail.start() + greenMail.setUser("alice@example.org", "secret") + // All IMAP traffic goes through the proxy so we can count it; the proxy forwards to GreenMail. + proxy = CountingImapProxy(backendHost = "127.0.0.1", backendPort = greenMail.imap.port) + } + + @After + fun tearDown() { + proxy.close() + greenMail.stop() + } + + /** Points [ImapClient] at the counting proxy rather than directly at GreenMail. */ + private fun params() = ImapConnectionParams( + host = "127.0.0.1", + port = proxy.port, + security = MailSecurity.NONE, + username = "alice@example.org", + secret = "secret", + useXoauth2 = false, + ) + + private fun seedInbox(count: Int) { + repeat(count) { i -> + GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Subject $i", "Body $i") + } + greenMail.waitForIncomingEmail(count) + } + + @Test + fun `each folder-open establishes a brand-new IMAP connection (no reuse today)`() = runTest { + seedInbox(2) + + repeat(OPENS) { client.fetchRecent(params(), "INBOX", limit = 50) } + + // One TCP connection per open: nothing is pooled or kept alive across folder-opens. A + // connection-reuse fix would make this strictly less than OPENS. + assertEquals(OPENS, proxy.connectionCount, "expected one fresh connection per folder-open") + } + + @Test + fun `each folder-open pays a fresh LOGIN and its own SELECT`() = runTest { + seedInbox(2) + + repeat(OPENS) { client.fetchRecent(params(), "INBOX", limit = 50) } + proxy.awaitClientStreamsSettled() + + // The avoidable round-trip: a full authentication on every open. Reuse would drop this to 1. + assertEquals(OPENS, proxy.authCommandCount(), "expected one LOGIN per folder-open") + // The necessary per-open work: READ_ONLY open issues EXAMINE. Reuse keeps this at one-per-open. + assertEquals(OPENS, proxy.commandCount("EXAMINE"), "expected one EXAMINE per folder-open") + } + + @Test + fun `a single folder-open's round-trip sequence is CONNECT-LOGIN-EXAMINE-FETCH-LOGOUT`() = runTest { + seedInbox(3) + + client.fetchRecent(params(), "INBOX", limit = 50) + proxy.awaitClientStreamsSettled() + + assertEquals(1, proxy.connectionCount, "one connection") + assertEquals(1, proxy.authCommandCount(), "one LOGIN — the connection-setup cost, avoidable on reuse") + assertEquals(1, proxy.commandCount("EXAMINE"), "one EXAMINE — the necessary per-folder SELECT") + assertTrue(proxy.commandCount("FETCH") >= 1, "at least one FETCH — the necessary header download") + assertEquals(1, proxy.commandCount("LOGOUT"), "one LOGOUT — the connection is torn down, not kept alive") + } + + @Test + fun `opening a folder then reading a message uses two separate connections (compounding cost)`() = runTest { + seedInbox(1) + + val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid // open folder -> connection 1 + client.fetchBodyMarkingSeen(params(), "INBOX", uid) // read a message -> connection 2 + proxy.awaitClientStreamsSettled() + + // No session is shared between listing the folder and reading a message in it: the read pays a + // second full CONNECT + LOGIN even though it targets the folder we just had open. + assertEquals(2, proxy.connectionCount, "list + read each open their own connection") + assertEquals(2, proxy.authCommandCount(), "list + read each pay a full LOGIN") + } + + private companion object { + const val OPENS = 3 + } +} diff --git a/docs/perf/issue-125-imap-folder-open.md b/docs/perf/issue-125-imap-folder-open.md new file mode 100644 index 0000000..aebc139 --- /dev/null +++ b/docs/perf/issue-125-imap-folder-open.md @@ -0,0 +1,175 @@ + +# IMAP folder-open latency (issue #125) + +Structural analysis of the IMAP round-trips paid when opening/selecting a folder, a follow-up to the +#86 profiling and distinct from the mailbox cached-render fix (#123) and the fetch policy (#88–#90). + +Performed 2026-07-02 against `main` by reading the folder-open path and confirming the round-trip +*structure* with deterministic GreenMail tests (`ImapFolderOpenLatencyTest` + +`CountingImapProxy`). + +**Verdict.** The folder-open network path re-establishes a **full, freshly-authenticated IMAP +connection on every operation** — there is no connection pooling or keep-alive. Each folder-open pays +`CONNECT + TLS + LOGIN + EXAMINE + FETCH + LOGOUT`; only the `EXAMINE + FETCH` is intrinsic to opening +a folder, and the entire `CONNECT + TLS + LOGIN` setup group (the majority of the round-trips) is +**avoidable on the second and subsequent operations** if a connection were reused. The recommended +mitigation is a per-account connection cache/keep-alive. It is **not implemented here**: the sizing, +eviction, stale-detection, and battery trade-offs are genuine latency/battery decisions that need +real-network + real-device measurement (which localhost GreenMail — ~0 RTT — cannot provide), and a +naïve implementation risks regressing the deliberate concurrency design and the IDLE connection budget. +This is the "spike first, measure before committing" the issue asks for. + +> **Note on numbers.** This document counts *protocol round-trips* (RTTs), which are deterministic and +> measurable in-process. It does **not** quote measured wall-clock latency — there is no real network +> or account in this environment. Where a millisecond figure appears it is explicitly *illustrative +> arithmetic* (`round-trips × RTT`), with RTT a placeholder for a real network's round-trip time. + +## The folder-open path + +Opening/selecting a folder in the UI runs two independent things: + +1. **Render from cache (already optimized, not the subject of #125).** + `MailboxViewModel.selectFolder()` sets `_selectedFolder` synchronously + (`MailboxViewModel.kt:307`). That immediately re-filters the cached Room rows into the list — no + network. #123 optimized this cached render. The network open below is *off* the render path, so its + cost shows up as a background refresh, not a blank screen. + +2. **Network sync (the subject of #125).** + `selectFolder()` then launches `mailSyncer.syncFolder(accountId, folder)`: + + ``` + MailboxViewModel.selectFolder() (MailboxViewModel.kt:307) + └─ MailSyncer.syncFolder() (MailSyncer.kt:80) + └─ syncFolderHeaders() (MailSyncer.kt:87) + ├─ connectionFactory.imapParamsFor(account) (resolves/refreshes credentials) + └─ imapClient.fetchRecent(params, folder, limit) (MailSyncer.kt:93) + └─ ImapClient.withStore { … } (ImapClient.kt:111, 521) + ``` + +`ImapClient.withStore()` is the crux (`ImapClient.kt:521`): + +```kotlin +private inline fun withStore(params: ImapConnectionParams, block: (Store) -> T): T { + val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol) + store.connect(params.host, params.port, params.username, params.secret) // CONNECT + TLS + LOGIN + return try { block(store) } finally { runCatching { store.close() } } // LOGOUT + teardown +} +``` + +**Every** `ImapClient` operation — `fetchRecent`, `fetchOlderThan`, `search`, `fetchBodyMarkingSeen`, +`fetchBodyPeek`, `fetchAttachment`, `setFlag`, `deleteMessage`, `moveMessages`, `fetchForReply` — is a +`withStore { … }`, so each one builds and authenticates its own connection and tears it down. Nothing +is reused between operations. + +## Per-open round-trip sequence + +For one `fetchRecent` (a folder-open), the client → server exchange is: + +| # | Step | RTTs | Necessary to *open a folder*? | +|---|------|------|-------------------------------| +| 1 | TCP handshake | ~1 | Setup — avoidable on reuse | +| 2 | TLS handshake (implicit TLS / `imaps`) | 1 (TLS 1.3) – 2 (TLS 1.2) | Setup — avoidable on reuse | +| 3 | `CAPABILITY` (Angus; reused from greeting when advertised) | 0–1 | Setup — avoidable on reuse | +| 4 | `LOGIN` / `AUTHENTICATE XOAUTH2` | 1 (+1 if challenged) | Setup — avoidable on reuse | +| 5 | `CAPABILITY` post-auth (reused from `LOGIN` response when advertised) | 0–1 | Setup — avoidable on reuse | +| 6 | `EXAMINE` (READ_ONLY select of the folder) | 1 | **Necessary** per folder | +| 7 | `FETCH` recent headers (`ENVELOPE FLAGS UID`) | 1 | **Necessary** header download | +| 8 | `LOGOUT` + socket teardown | ~1 | Setup — avoidable on reuse | + +- **STARTTLS (`imap` on 143)** is worse: it inserts a pre-TLS `CAPABILITY`, the `STARTTLS` command, + then a post-TLS `CAPABILITY` *before* step 4 — roughly **6–8 setup RTTs** instead of 4–6. +- **Setup (steps 1–5, 8): ~4–6 RTT (imaps) / ~6–8 RTT (STARTTLS).** +- **Intrinsic folder work (steps 6–7): 2 RTT.** + +So the connection setup is the **majority** of the round-trips on every open, and it is exactly the +part a reused connection would skip. Illustratively, at an RTT of *R*: a cold open ≈ `(4–6)·R` setup + +`2·R` work; a warm (reused-connection) open ≈ `2·R`. The setup share — everything except the +`EXAMINE + FETCH` — is what a fix removes from the 2nd open onward. + +### Compounding across operations + +Because the pattern is per-operation, costs stack: + +- **Folder switch A → B → A:** 3 folder-opens ⇒ 3 full `CONNECT + TLS + LOGIN` setups. +- **List then open a message:** `fetchRecent` (open) + `fetchBodyMarkingSeen` (read) ⇒ 2 full setups, + even though the read targets the folder just listed (proven by the test below). +- **Prefetch after a sync** (`MailSyncer.prefetchIfEnabled`, FetchPolicy territory #88–#90, *not* + changed here): each unfetched message body is another `withStore` connection, and each attachment + another still. A folder-open that triggers prefetch of *K* messages can open `1 + K + attachments` + separate authenticated connections. This amplifies the motivation for pooling but is out of scope. + +## Deterministic evidence (no real network needed) + +`ImapFolderOpenLatencyTest` routes `ImapClient` through `CountingImapProxy` — a localhost TCP proxy +that forwards a cleartext IMAP session to in-process GreenMail while counting connections and parsing +IMAP command words. This measures the *structure* exactly, without needing real latency: + +- `each folder-open establishes a brand-new IMAP connection (no reuse today)` — N opens ⇒ **N** TCP + connections. +- `each folder-open pays a fresh LOGIN and its own SELECT` — N opens ⇒ **N** `LOGIN` **and** N + `EXAMINE` (the avoidable auth vs. the necessary select). +- `a single folder-open's round-trip sequence is CONNECT-LOGIN-EXAMINE-FETCH-LOGOUT` — pins the + sequence: 1 connection, 1 `LOGIN`, 1 `EXAMINE`, ≥1 `FETCH`, 1 `LOGOUT`. +- `opening a folder then reading a message uses two separate connections (compounding cost)` — list + + read ⇒ **2** connections and **2** `LOGIN`s. + +These assertions encode the *current* (no-reuse) behaviour and double as the **validation harness for a +future fix**: once a connection is reused across folder switches, the connection/auth counts drop below +the operation count — flip the expectations to assert reuse and the same real-IMAP tests confirm the win. + +## Recommended mitigation: per-account connection reuse / keep-alive + +Keep one authenticated `Store` alive per account and reuse it across folder-opens and message +operations instead of `withStore`'s connect-per-call, so only the first operation pays setup and +subsequent ones pay just `EXAMINE + FETCH`. Design constraints that make this **non-trivial** and why +it needs measurement before landing: + +1. **Must not disturb IMAP IDLE (#90).** `IdleService` already holds a *separate*, dedicated + long-lived `Store` per account (`ImapClient.idle`, `IdleService.watchAccount`), blocking on + `INBOX.idle()`. IMAP is serial per connection and IDLE blocks its connection, so folder-opens + cannot be multiplexed onto it. A reuse pool is therefore an **additional** persistent connection + per account (IDLE + pool), which must respect the server's per-account connection limit (Gmail + ~15; many servers 3–5) — a budget `ImapClient.idle`'s own comment already flags. +2. **Thread-safety.** `MailRepositoryImpl`'s UI operations (`openMessage`, `setStarred`, + `deleteMessage`, `moveMessages`, `setFlag`, …) are **not** serialized and can overlap `MailSyncer` + (whose `prefetchIfEnabled` deliberately runs *outside* `syncMutex` so downloads don't block + pull-to-refresh). Today's connect-per-call sidesteps this. A shared connection needs its own + discipline: a single mutex-guarded connection (simplest, but head-of-line-blocks a flag toggle + behind a slow body download — a regression of the current concurrency) **or** a small bounded pool + of N connections (more throughput, needs a size cap + eviction). Choosing between them is a + latency/throughput trade-off that needs real measurement. +3. **Stale-connection handling.** A pooled socket can be dropped by the server's idle timeout + (RFC-permitted), NAT rebinding, or a network change. Reuse must detect staleness — a `NOOP` probe + (adds 1 RTT, partly defeating the point) or catch-and-retry-once on a fresh connection — behaviour + best validated against real servers and real network transitions. +4. **Battery / lifecycle (#88/#89/#90).** Holding a socket open has a battery cost; #90 already tears + IDLE down at low battery. A reuse pool needs an idle-eviction timeout and should likely mirror that + low-battery posture. The right timeout is a battery-vs-latency trade-off that needs device + measurement. + +Because every one of these knobs (mutex vs. pool, eviction timeout, stale-probe strategy, battery +posture) trades latency against battery/complexity and can only be tuned with a real network and a +real device — which this environment cannot provide — forcing an implementation now would be guessing. +Per #125's "investigation/spike first" guidance, this change ships the measurement harness + analysis +and **defers the pool to a measured follow-up**. + +**Already correct — do not redo.** Optimistic render-from-cache is already the architecture +(`selectFolder` renders cached rows instantly; the network sync is a background refresh). #125's +"optimistic render while the network catches up" is satisfied; only connection reuse remains. + +## What a maintainer needs to fully close #125 (real device + real account) + +1. **Instrument the open.** Add timing around `syncFolder → fetchRecent → store.connect / open / fetch + / close` (or enable Angus `mail.imap` debug) and capture on a real Gmail/Outlook account over both + Wi-Fi and cellular. +2. **Attribute the wall-clock.** Break each open into connect (TCP+TLS), login, `EXAMINE`, `FETCH`, + `LOGOUT`; confirm the hypothesis that connection setup dominates and quantify its share. +3. **A/B the pool behind a flag.** Measure folder-switch latency (open A → open B → back to A) and + list-then-open-message latency, cold vs. warm-reuse, on the same accounts/networks. Expect warm + opens to fall by the connection-setup share. +4. **Battery check.** Measure the kept-alive socket's idle cost against candidate eviction timeouts; + confirm no regression versus the #88/#89/#90 posture. +5. **Resilience check.** Force server idle-timeout and network transitions; confirm transparent + reconnect with no user-visible failures, and that IDLE + pool stay within the per-account limit. +6. **Lock it in.** Flip `ImapFolderOpenLatencyTest` to assert reuse (connection/auth counts < operation + count) as the deterministic regression guard.