Opening an uncached message stalled ~35-74s (avg 48s) behind the reader spinner because the on-demand IMAP body fetch has no priority over the continuous full-history backfill (#12) and loses the race for the account's IMAP throughput (ImapClient is connect-per-operation, and the interactive fetch shares no in-process lock with backfill). This makes an interactive open pre-empt the background backfill so the body the user is waiting on is fetched first.
Closes #355
What changed
New InteractiveImapGate (@Singleton) — a process-wide priority signal mirroring MailMaintenanceGate / AccountThrottleGate. A counter (not a mutex) so overlapping interactive fetches run concurrently and backfill waits for all to clear.
withInteractive { } raises an in-flight counter for the block and always lowers it in a finally (a failed fetch can never strand it).
awaitInteractiveIdle() suspends until the counter hits zero (StateFlow.first { it == 0 }, no lost-wakeup).
MailRepositoryImpl wraps the user-facing IMAP paths in withInteractive { }: openMessage, inlineImages, downloadAttachment, buildReplyDraft. Backfill's own content prefetch now calls ensureAttachmentFile directly so it bypasses the gate (it must not yield to itself; also removes a latent per-part routing re-read).
MailBackfiller parks at its natural per-page yield point (yieldToInteractive) while an interactive fetch is active, resuming the instant it clears. This is also the slice's first yield point, so a slice never begins a page while the user waits on a body.
PII-free AppLog breadcrumbs (accountLogRef) at the backfill park/resume points (see #358).
Orthogonal and complementary to AccountThrottleGate (#360, merged in #436): that gate makes background work back off after a provider rejects it; this gate makes background work yield to a foreground fetch pre-emptively. MailBackfiller now consults both — the throttle backoff per account, then the interactive gate per page.
Tests
InteractiveImapGateTest — counter raise/clear, park-until-release, release-on-throw (no deadlock), multi-fetch hold-until-last, Turbine on the count flow.
MailBackfillerTest — backfill parks before its next page while an interactive fetch holds the gate and resumes after (asserts the park/resume breadcrumbs); errored interactive fetch does not strand backfill.
MailRepositoryImplTest — openMessage holds the gate for the whole body fetch and releases it after.
InteractiveImapGateInstrumentedTest (androidTest) — on-device pause/resume + concurrent-hold + error-release, mock-free, for the CI API matrix.
Gate results (local, JDK 21)
assembleDebug, testDebugUnitTest, jacocoTestCoverageVerification, compileDebugAndroidTestKotlin, lintDebug, ktlintCheck, detekt — all green. Local emulator preflight skipped (flaky/wedges per repo guidance); CI's full matrix E2E is the authoritative gate.
Sequencing note
Touches the same sync surface as siblings #356 (bound backfill), #357 (fast first-open), #358 (observability). The gate is additive (new @Singleton + a per-page yield point), so it should merge cleanly alongside them; the per-page park combines naturally with #356's bounded backfill for the open-lands-mid-page case.
## Summary
Opening an uncached message stalled ~35-74s (avg 48s) behind the reader spinner because the on-demand IMAP body fetch has **no priority** over the continuous full-history backfill (#12) and loses the race for the account's IMAP throughput (`ImapClient` is connect-per-operation, and the interactive fetch shares no in-process lock with backfill). This makes an interactive open **pre-empt** the background backfill so the body the user is waiting on is fetched first.
`Closes #355`
## What changed
- **New `InteractiveImapGate` (`@Singleton`)** — a process-wide priority signal mirroring `MailMaintenanceGate` / `AccountThrottleGate`. A counter (not a mutex) so overlapping interactive fetches run concurrently and backfill waits for *all* to clear.
- `withInteractive { }` raises an in-flight counter for the block and **always** lowers it in a `finally` (a failed fetch can never strand it).
- `awaitInteractiveIdle()` suspends until the counter hits zero (`StateFlow.first { it == 0 }`, no lost-wakeup).
- **`MailRepositoryImpl`** wraps the user-facing IMAP paths in `withInteractive { }`: `openMessage`, `inlineImages`, `downloadAttachment`, `buildReplyDraft`. Backfill's own content prefetch now calls `ensureAttachmentFile` directly so it **bypasses** the gate (it must not yield to itself; also removes a latent per-part routing re-read).
- **`MailBackfiller`** parks at its natural per-page yield point (`yieldToInteractive`) while an interactive fetch is active, resuming the instant it clears. This is also the slice's first yield point, so a slice never begins a page while the user waits on a body.
- **PII-free `AppLog` breadcrumbs** (`accountLogRef`) at the backfill park/resume points (see #358).
## How this hooks into #360's framework
Orthogonal and complementary to `AccountThrottleGate` (#360, merged in #436): that gate makes background work **back off after a provider rejects it**; this gate makes background work **yield to a foreground fetch pre-emptively**. `MailBackfiller` now consults both — the throttle backoff per account, then the interactive gate per page.
## Tests
- `InteractiveImapGateTest` — counter raise/clear, park-until-release, **release-on-throw (no deadlock)**, multi-fetch hold-until-last, Turbine on the count flow.
- `MailBackfillerTest` — backfill parks before its next page while an interactive fetch holds the gate and resumes after (asserts the park/resume breadcrumbs); errored interactive fetch does not strand backfill.
- `MailRepositoryImplTest` — `openMessage` holds the gate for the whole body fetch and releases it after.
- `InteractiveImapGateInstrumentedTest` (androidTest) — on-device pause/resume + concurrent-hold + error-release, mock-free, for the CI API matrix.
## Gate results (local, JDK 21)
`assembleDebug`, `testDebugUnitTest`, `jacocoTestCoverageVerification`, `compileDebugAndroidTestKotlin`, `lintDebug`, `ktlintCheck`, `detekt` — **all green**. Local emulator preflight skipped (flaky/wedges per repo guidance); CI's full matrix E2E is the authoritative gate.
## Sequencing note
Touches the same sync surface as siblings #356 (bound backfill), #357 (fast first-open), #358 (observability). The gate is additive (new `@Singleton` + a per-page yield point), so it should merge cleanly alongside them; the per-page park combines naturally with #356's bounded backfill for the open-lands-mid-page case.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Opening an uncached message stalled ~35-74s (avg 48s) behind the reader spinner because the on-demand IMAP body fetch has no priority over the continuous full-history backfill (#12) and loses the race for the account's IMAP throughput (
ImapClientis connect-per-operation, and the interactive fetch shares no in-process lock with backfill). This makes an interactive open pre-empt the background backfill so the body the user is waiting on is fetched first.Closes #355What changed
InteractiveImapGate(@Singleton) — a process-wide priority signal mirroringMailMaintenanceGate/AccountThrottleGate. A counter (not a mutex) so overlapping interactive fetches run concurrently and backfill waits for all to clear.withInteractive { }raises an in-flight counter for the block and always lowers it in afinally(a failed fetch can never strand it).awaitInteractiveIdle()suspends until the counter hits zero (StateFlow.first { it == 0 }, no lost-wakeup).MailRepositoryImplwraps the user-facing IMAP paths inwithInteractive { }:openMessage,inlineImages,downloadAttachment,buildReplyDraft. Backfill's own content prefetch now callsensureAttachmentFiledirectly so it bypasses the gate (it must not yield to itself; also removes a latent per-part routing re-read).MailBackfillerparks at its natural per-page yield point (yieldToInteractive) while an interactive fetch is active, resuming the instant it clears. This is also the slice's first yield point, so a slice never begins a page while the user waits on a body.AppLogbreadcrumbs (accountLogRef) at the backfill park/resume points (see #358).How this hooks into #360's framework
Orthogonal and complementary to
AccountThrottleGate(#360, merged in #436): that gate makes background work back off after a provider rejects it; this gate makes background work yield to a foreground fetch pre-emptively.MailBackfillernow consults both — the throttle backoff per account, then the interactive gate per page.Tests
InteractiveImapGateTest— counter raise/clear, park-until-release, release-on-throw (no deadlock), multi-fetch hold-until-last, Turbine on the count flow.MailBackfillerTest— backfill parks before its next page while an interactive fetch holds the gate and resumes after (asserts the park/resume breadcrumbs); errored interactive fetch does not strand backfill.MailRepositoryImplTest—openMessageholds the gate for the whole body fetch and releases it after.InteractiveImapGateInstrumentedTest(androidTest) — on-device pause/resume + concurrent-hold + error-release, mock-free, for the CI API matrix.Gate results (local, JDK 21)
assembleDebug,testDebugUnitTest,jacocoTestCoverageVerification,compileDebugAndroidTestKotlin,lintDebug,ktlintCheck,detekt— all green. Local emulator preflight skipped (flaky/wedges per repo guidance); CI's full matrix E2E is the authoritative gate.Sequencing note
Touches the same sync surface as siblings #356 (bound backfill), #357 (fast first-open), #358 (observability). The gate is additive (new
@Singleton+ a per-page yield point), so it should merge cleanly alongside them; the per-page park combines naturally with #356's bounded backfill for the open-lands-mid-page case.Merge Queue Status
2026-07-08 20:49 UTC· Rule:default· triggered by merge protections2026-07-08 21:21 UTC· at52da77b6a0494d1e6f5dfa9f49a1b25ea7b6324d· mergeThis pull request spent 32 minutes 7 seconds in the queue, including 26 minutes 16 seconds running CI.
Required conditions to merge
-conflict-draftbase = maincheck-success = CI passedgithub-review-approved[🛡 GitHub repository ruleset rulemain]label != brokencheck-success = Debug buildcheck-neutral = Debug buildcheck-skipped = Debug buildcheck-success = Unit testscheck-neutral = Unit testscheck-skipped = Unit testscheck-success = CI passedcheck-neutral = CI passedcheck-skipped = CI passedmain]:check-success = @github-actions/CI passedcheck-neutral = @github-actions/CI passedcheck-skipped = @github-actions/CI passed