test(sync): assert MailMaintenanceGate serializes backfill and prune

The gate's "backfill and prune never interleave" guarantee was only argued
structurally: the MailBackfiller/MailPruner unit tests each build a throwaway,
uncontended gate, so the serialization is never exercised. Add MailMaintenanceGateTest:

- oneGateSerializesConcurrentCriticalSections: 50 coroutines contend on one gate;
  an overlap counter must never exceed 1 (guards against a per-access mutex).
- aConcurrentPruneWaitsForAnInFlightBackfillToReleaseTheGate: a real MailBackfiller
  parks inside the gate (its fetchOlderThan suspended) while a real MailPruner is
  launched concurrently; the two share ONLY the gate, and the prune provably cannot
  enter its critical section until the backfill releases.

Turns the defence-in-depth guarantee from argued to asserted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 14:52:36 -05:00
co-authored by Claude Opus 4.8
parent a73b6410a2
commit 1d4103747f
@@ -0,0 +1,201 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.yield
import org.junit.Test
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.libremail.data.settings.AccountSettingsRepository
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.AccountSettings
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
import org.libremail.mail.FetchedMessage
import org.libremail.mail.ImapClient
import java.util.Collections
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.assertEquals
/**
* The [MailMaintenanceGate]'s job is to serialize the two background maintenance jobs that both touch
* older cached history — the full-history backfill ([MailBackfiller], #12) and the retention pruner
* ([MailPruner], #13) — so they can never interleave (defence in depth behind the disjoint retention
* floor). The `MailBackfiller`/`MailPruner` unit tests each construct a throwaway, *uncontended* gate,
* so the serialization itself is never exercised there. These tests do: one asserts the gate's core
* contract (a single exclusive lock), the other wires a real backfiller and a real pruner to the SAME
* gate — the only object they share — and proves a concurrent prune blocks until the backfill releases.
*/
class MailMaintenanceGateTest {
private val accountEntity = AccountEntity(
id = "acct",
email = "alice@example.org",
displayName = "Alice",
authType = "PASSWORD_IMAP",
imap = ServerConfigEmbedded("127.0.0.1", 993, "NONE"),
smtp = ServerConfigEmbedded("127.0.0.1", 465, "NONE"),
)
/**
* The gate's contract: everyone who acquires the *same* gate takes the *same* exclusive lock, so no
* two critical sections overlap. Guards against a regression that hands out a fresh lock per access
* (e.g. `val mutex get() = Mutex()`), which would compile but silently drop all serialization.
*/
@Test
fun oneGateSerializesConcurrentCriticalSections() = runBlocking {
val gate = MailMaintenanceGate()
val inside = AtomicInteger(0)
val maxObserved = AtomicInteger(0)
val jobs = (1..50).map {
launch(Dispatchers.Default) {
gate.mutex.withLock {
val depth = inside.incrementAndGet()
maxObserved.getAndUpdate { current -> maxOf(current, depth) }
yield() // invite a sibling to (wrongly) enter while we still hold the lock
inside.decrementAndGet()
}
}
}
jobs.joinAll()
assertEquals(1, maxObserved.get(), "one gate must never allow two critical sections at once")
}
/**
* A real backfill slice holds the gate across its (here, suspended) server fetch; a prune launched
* concurrently must not enter its own critical section until the backfill releases the gate. The
* backfiller and pruner share *only* the [MailMaintenanceGate], so a pass is attributable solely to
* the gate.
*/
@Test
fun aConcurrentPruneWaitsForAnInFlightBackfillToReleaseTheGate() = runBlocking {
val gate = MailMaintenanceGate()
val events = Collections.synchronizedList(mutableListOf<String>())
val backfillInside = CompletableDeferred<Unit>()
val releaseBackfill = CompletableDeferred<Unit>()
val backfiller = backfiller(gate, events, backfillInside, releaseBackfill)
val pruner = pruner(gate, events)
// Backfill grabs the gate and parks inside it (its fetch is suspended on releaseBackfill).
val backfillJob = launch(Dispatchers.Default) { backfiller.runBackfill() }
backfillInside.await()
// Prune now tries to run; it must block on the gate the backfill still holds.
val pruneJob = launch(Dispatchers.Default) { pruner.prune() }
delay(200)
assertEquals(
listOf("backfill-in"),
events.toList(),
"prune must not enter its critical section while backfill holds the gate",
)
// Release the backfill; only now may prune acquire the gate and run.
releaseBackfill.complete(Unit)
backfillJob.join()
pruneJob.join()
assertEquals(
listOf("backfill-in", "prune-in"),
events.toList(),
"prune runs only after backfill releases the gate — never interleaved",
)
}
/**
* A [MailBackfiller] whose single server fetch parks inside the gate: it logs `backfill-in`, signals
* [backfillInside], then suspends on [releaseBackfill] while still holding the lock. Everything else
* is stubbed so exactly one bounded page is attempted.
*/
private fun backfiller(
gate: MailMaintenanceGate,
events: MutableList<String>,
backfillInside: CompletableDeferred<Unit>,
releaseBackfill: CompletableDeferred<Unit>,
): MailBackfiller {
val accountDao = mockk<AccountDao>()
coEvery { accountDao.getAll() } returns listOf(accountEntity)
val messageDao = mockk<MessageDao>(relaxed = true)
coEvery { messageDao.syncedFolders("acct") } returns listOf("INBOX")
val imapClient = mockk<ImapClient>()
coEvery { imapClient.fetchOlderThan(any(), any(), any(), any()) } coAnswers {
events.add("backfill-in")
backfillInside.complete(Unit)
releaseBackfill.await()
emptyList<FetchedMessage>()
}
val connectionFactory = mockk<MailConnectionFactory>()
coEvery { connectionFactory.imapParamsFor(any()) } returns ImapConnectionParams(
host = "h",
port = 143,
security = MailSecurity.NONE,
username = "u",
secret = "s",
useXoauth2 = false,
)
val settingsRepository = mockk<SettingsRepository>()
every { settingsRepository.settings } returns flowOf(AppSettings())
val accountSettingsRepository = mockk<AccountSettingsRepository>()
coEvery { accountSettingsRepository.get("acct") } returns AccountSettings("acct")
return MailBackfiller(
context = mockk(relaxed = true),
accountDao = accountDao,
messageDao = messageDao,
backfillProgressDao = mockk(relaxed = true),
imapClient = imapClient,
connectionFactory = connectionFactory,
settingsRepository = settingsRepository,
accountSettingsRepository = accountSettingsRepository,
mailRepository = mockk(relaxed = true),
maintenanceGate = gate,
)
}
/**
* A [MailPruner] that logs `prune-in` the instant it enters its critical section, then no-ops
* because retention is unlimited.
*/
private fun pruner(gate: MailMaintenanceGate, events: MutableList<String>): MailPruner {
val accountDao = mockk<AccountDao>()
coEvery { accountDao.getAll() } returns listOf(accountEntity)
val settingsRepository = mockk<SettingsRepository>()
every { settingsRepository.settings } answers {
events.add("prune-in") // first read happens inside prune()'s withLock
flowOf(AppSettings())
}
val accountSettingsRepository = mockk<AccountSettingsRepository>()
coEvery { accountSettingsRepository.get("acct") } returns AccountSettings("acct")
return MailPruner(
context = mockk(relaxed = true),
accountDao = accountDao,
messageDao = mockk(relaxed = true),
settingsRepository = settingsRepository,
accountSettingsRepository = accountSettingsRepository,
maintenanceGate = gate,
)
}
}