Files
LibreMail/docs/perf/issue-125-imap-folder-open.md
T
JMR-devandClaude Fable 5 b0bb942a02 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 <noreply@anthropic.com>
2026-07-02 08:24:18 -05:00

12 KiB
Raw Blame History

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):

private inline fun <T> 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 LOGINs.

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.

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.