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

176 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
# 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 <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** `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.