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>
This commit is contained in:
2026-07-02 08:24:18 -05:00
co-authored by Claude Fable 5
parent 48333ab01e
commit b0bb942a02
3 changed files with 463 additions and 0 deletions
@@ -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<String, AtomicInteger>()
/** Client → server pump threads, tracked so tests can wait for the parsed command stream to settle. */
private val clientPumps = Collections.synchronizedList(mutableListOf<Thread>())
@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 `<tag> <COMMAND> [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
}
}
@@ -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
}
}