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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user