Make the oversized-metadata quarantine durable so sync can complete - #105
Merged
Conversation
A message whose IMAP metadata FETCH overflows the response parser's buffer
was quarantined only in `oversizedDeferredThisSession`, an in-memory Set that
is rebuilt empty on every launch. Every launch therefore re-fetched every
oversized message and failed again — and each failure is expensive rather than
merely wasted, because `withFolderConnection` classifies `PayloadTooLargeError`
as unhealthy, tears the connection down, and makes the next attempt pay a full
TCP + TLS + LOGIN + SELECT.
Adds `messageHeader.bodyMetadataOversized`, written beside the existing
in-memory insert in both queues' `handlePayloadTooLarge` singleton branch —
the one place that is already attributed (`items.count == 1`) and already
generation-guarded. The four body-fetch admission queries exclude it.
The flag records an OBSERVATION about one wire attempt, not a verdict. The
parser bound is on unread aggregate bytes measured after the decode loop
stops, so it is fragmentation-dependent: the same message can overflow on a
lossy link and parse fine on WiFi. Six comments that described the failure as
"size-deterministic per binary" are corrected. Nothing is marked complete or
empty, and the row stays fetchable on the on-demand path.
Deliberate non-changes, each pinned by a test so a later cleanup cannot
quietly undo them:
- `BackfillProgress.pendingBodyCount` still counts the row, so the
"Sync Complete" banner stays honest about a genuinely missing body.
- `SyncEngineFTS.selfHealBackfillFTSMembership` still sees it, so the
healthy header stays searchable by subject and sender.
Index: the admission queries gain a fifth equality predicate, so
`messageHeader_bodyRepopulate` (v40) is extended by one column into
`messageHeader_bodyRepopulateV2`, keeping `date` last so one index serves the
seek and the ORDER BY. Measured with EXPLAIN QUERY PLAN: all five equality
columns enter the seek with no temp B-tree. A partial index was tried first
and rejected — the planner never chose it over v40's, so it would have been
pure write amplification. Per ADR-IOS-029 the v40 index is not dropped.
The two dispatched writes are serialized behind one chain. `handlePayloadTooLarge`
and `clearOversizedDeferred` are deliberately synchronous, so the durable write
cannot be awaited inline; but mark and clear can be dispatched microseconds
apart by a UIDVALIDITY reset, and if they reordered the clear would run first
and the mark would re-flag a row whose address no longer refers to the same
message.
Releases: a UIDVALIDITY reset and Smart Reindex clear the flag, and the
migration that ships a raised parser bound clears it in one exact statement —
possible only because nothing else writes this column. That replaces the old
"relaunch gives a fresh attempt" release with one targeted retry when the
bound actually changes.
Verification: 9,354 tests, 1,258 suites. Red-first evidence recorded by
inverting the fix — five tests fail without the write, including both
relaunch invariants. Also repairs four migration tests that decoded or
inserted the current model against a deliberately older schema, a latent
fragility that any future non-optional column would have tripped.
Does not make the body fetchable; that needs the upstream parser fix, tracked
separately. Issue #74 stays open.
Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Owner decision 2026-09-01, reversing two deliberate non-changes in the oversized-metadata quarantine. A message whose metadata FETCH overflows the IMAP response parser cannot be fetched by this build; treating that as outstanding work nags the user forever about something no amount of waiting fixes. Backfill progress now counts a flagged row as settled rather than pending, so pendingBodyCount can reach 0, isFullyComplete becomes true, the sync banner clears and Fast Sync stops keeping the device awake. The indexed numerator moves with it, or the progress bar would park one short of 100% beside a green completion check. Both predicates are now named requests on MessageHeader (pendingBodyRequest / bodySettledRequest) instead of inline filter chains: a GRDB chain is invisible to every SQL-text census, and the tests that covered it were hand-copied replicas, which cannot fail when the predicate changes. Opening a flagged message now reports the load-failed state immediately - error set, not loading, no body, header rendered - without a wire attempt and without starting the 2s poll. The attempt would cost a full TCP+TLS+LOGIN+ SELECT (the overflow marks the folder connection unhealthy) and end in this same state, and no background path can produce the body because the flag is what removed it from both queues. Pull-to-refresh is deliberately unchanged and still performs a genuine fetch: the parser bound is fragmentation-dependent, so the same message can succeed on a different connection. That retry is what keeps the flag an observation rather than a verdict. Verification: full suite 9,359 tests / 1,259 suites green, one pre-declared known issue, single test-run line. Red-first established by inverting both changes - 4 tests fail with 7 issues, while the controls (the identical unflagged fixture still fetches, a durable body outranks the flag, and the whole wake-lock suite) stay green. Accepted limitations, recorded at the mechanism in BackfillBodyQueue and on MessageHeader.bodyMetadataOversized: "Sync Complete" can now fire on an account that still holds an unfetchable body, and such a message is unopenable without a pull-to-refresh. Both are the owner's call and revisit when the parser bound is raised upstream. The superseded reasoning is kept in place in the source, the tests and the plan rather than deleted, so it is not re-derived later. Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Review round 1 found the stop-gap's own regression: nothing ever cleared
`bodyMetadataOversized`, so the flag outlived the observation it recorded.
`BodyAssetMaintenance` evicts a `messageBody` row while deliberately leaving
`bodyComplete = 1`, and the detail view's cache-miss fetch is the only recovery
for that. The open-path short-circuit, keyed on the flag alone, deleted that
recovery: a message this build had already fetched successfully became
permanently unopenable — strictly worse than the bug being fixed. Smart Reindex
had a second face of the same defect, setting `bodyComplete = 0` on a healthy
flagged row.
Outcome:
- All four success writes now clear the flag — `BodyFetchProcessor.flushBatch`
(body branch and confirmed-empty branch), `NSEDataBridge.flushNSEBatchToFTS`,
`SyncEngine.applySnippetUpdates`. A written body is positive evidence that
refutes an overflow observation.
- The mark carries `AND bodyComplete = 0`, so a row that already has a body can
never acquire the flag; the write is dispatched, so a pull-to-refresh can win
the race.
- The open path adds `&& !msg.bodyComplete` as the fail-safe for any residual
flag, keeping eviction's designed recovery reachable.
Second round-1 finding: `v88_addBodyMetadataOversized` built
`messageHeader_bodyRepopulateV2` inside the migration, i.e. on the blocking
launch path, contrary to ADR-IOS-029's 2026-08-05 owner amendment ("only things
that are absolutely necessary and blocking"). The index passes that ADR's own
eligibility test — its absence degrades performance and nothing else — so it
moved to `SyncEngine.deferredIndexes`, following the `v83` precedent whose body
is intentionally empty for the same reason. The migration is now one
`ADD COLUMN`.
Third round-1 finding: three quarantine suites dispatched escaped durable writes
at the process-global `AppDatabase.shared` without `.processGlobalState` and
without draining them, so a write could land in a later suite's swapped
database. They now carry the trait and drain every queue they touch, as does the
UIDVALIDITY-reset companion suite.
Verification — 65 tests in 11 suites green, full suite 9,370 tests in
1,259 suites green, and red-first
proved by focused mutation for each new invariant:
- remove the open path's `!msg.bodyComplete` -> evicted-row recovery test fails
- remove `bodyMetadataOversized = 0` from the flushBatch success write -> the
success-clears-the-flag test fails
- narrow `resetCrawlState`'s WHERE back to `bodyEmptyConfirmed = 1` -> the Smart
Reindex test fails
- put the CREATE INDEX back in v88 -> the migration-shape test fails
- drop the conjunct from either `repopulateFromDatabase` -> that queue's
relaunch test fails
- flag on a stale generation -> the generation-guard test fails
- remove `await previous?.value` from `enqueueDurableWrite` -> the ordering
tests fail, each against the mutant that delays the write it would otherwise
be blind to (latency on the mark fails release-after-mark; latency on the
clear fails mark-after-release)
Tests now drive production symbols rather than replicas: the relaunch tests call
the real `repopulateFromDatabase()`, the Smart Reindex test calls the real
`SyncEngine.resetCrawlState()`, and the success-clears test calls the real
`BodyFetchProcessor.flushBatch`. The Active queue gained mirrors of the three
durable-write tests that previously ran only on Backfill.
Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
…ession `assertIMAPTeardown` read `FakeIMAPServer.liveSessionCount()` immediately after `provider.disconnect()` returned. Disconnect returns once the CLIENT has closed its socket; the fake server drops the fd from `loggedInFds` on its own `handleClient` thread, in `closeClientFd`, when it observes that close. Nothing synchronises the two, so the assertion races the server thread and a loaded machine loses that race. Measured during a full-suite run at load average ~140: `liveSessionCount()` read 1 immediately, healed to 0 after 50ms, and `abandonedSessionCount()` was 0 — nothing had actually been abandoned, the oracle had simply looked too early. The failure appeared on six full-suite runs and never once when the suite was run in isolation, which is the signature of the race rather than of a leak. The check now waits for the transport to settle, bounded at 2s: a session genuinely left live never heals and still fails, and the message reports how long it waited. This does not weaken the abandoned-session invariant — `abandonedSessionCount()` is monotonic by construction, never heals, and remains asserted exactly by the callers that care about it. Verification: full suite green, 9,381 tests in 1,260 suites, one pre-existing known issue, no host restarts. Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Round-2 audit finding: the stop-gap gated the four background admission
queries and the user-open path, but left two automatic fetch initiators
live, and threw away the observation made by the path that detects the
overflow soonest.
Closed all three:
- `InboxViewModel.loadSnippetBatch` tier 2 calls the same
`provider.fetchMessage` that overflowed. It is now refused before the
network and blacklisted for the session. This one mattered most:
`reloadMessages` clears `snippetFailed` and re-queues the visible
window, so an ungated row was retried on every reload.
- `MessageDetailViewModel`'s body poll. `loadBody`'s three cancelled-read
exits all `startBodyPoll(); return` BEFORE its quarantine branch, and
`refetchBody` restarts the poll whenever a retry produced no body — so
a flagged row retried every 2s indefinitely, each attempt paying a
full TCP+TLS+LOGIN+SELECT. The poll now reads the flag fresh (the
queues can mark a row while it runs), reports the load failure and
ends.
- `BodyFetchProcessor.fetch`'s `PayloadTooLargeError` branch now records
the observation, under the same `AND bodyComplete = 0` guard the two
queues use. A comment there claimed the omission was harmless because
"that path performs no retry loop"; it does — `loadBody` ends with
`if messageBody == nil { startBodyPoll() }`. Without this the flagged
population was "whatever a background queue reached first".
The read-side predicate is now one symbol, `MessageHeader
.isBodyQuarantined`, shared by all three code initiators; the four
admission queries are the SQL half and are now single symbols too
(`Active`/`BackfillBodyQueue.admissionSQL`), replacing two byte-identical
copies per queue plus a test replica.
Also from the round, all verified against source before acting:
- `StuckMessageDiagnostics` reported quarantined rows as "pending",
i.e. as work still queued. Split into its own bucket, keeping the four
bodyless buckets an exact partition, and the flag added to the sample
dump.
- Deleted a vacuous test (`flaggingNeverRetiresTheRow` asserted only
that its own `UPDATE` left other columns alone) and moved the real
claim onto the queue-driven tests.
- Three test replicas replaced by the production symbols they copied —
`admissionSQL`, `backfillFTSSelfHealCandidateSQL`, and the eligibility
helper, which now drives the real queue's `repopulateFromDatabase()`.
- A leaked body poll in `pullToRefreshStillRetries` (its 2s tick calls
the live `AccountManager`) is now stopped in `defer`.
Corrected four claims in comments that were false:
- the flag's consumer census (three consumers, now five) and its clear
census — `rg 'bodyMetadataOversized = 0'` finds only three of the four
success writes, because `SyncEngine.applySnippetUpdates` writes it as
a GRDB `updateAll` chain and is invisible to SQL-text search;
- `BackfillProgress`'s "empty/404/oversized bodies confirm-empty" — the
exact conflation this change removes, since confirming an oversized
body empty is the data-integrity-rule-1 violation that shipped once;
- `clearOversizedDurably`'s "cannot drift from `MessageIdentity`'s
parsing" — it can, for a mid-optimistic-move row; the divergence is
benign in both directions and now says why it is kept;
- the v40 index's "(other queries use it)" — unverified, and not the
reason; it is the measured fallback plan for these same four queries.
Tests: 13 new, red-first proved in two mutation batches (production
gates; write guards and clear sites), each target test red with its
controls green, restored by file backup and verified byte-identical.
Added a partition property test over the whole disposition table, since
`pendingBodyCount` and the indexed numerator are the same question from
opposite sides and the stop-gap added a third settled disposition.
Verification: full suite green on a dedicated simulator, 9,381 tests in
1,260 suites (9,370 before; 13 added, 2 removed), one pre-existing known
issue, no host restarts, no new compiler warnings.
Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
…tion `tabmail-ios` is a public repository, and this doc comment carried a verbatim quote from a private project conversation, attributed with a gendered pronoun for a real person. Neither belongs in published source, and neither carried information the comment needs. Replaced with a plain statement of the criterion itself — the admission query must be provably optimal under the new index — which is what the test actually gates. No test behaviour changes. Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Round-3 audit fixes for the oversized-metadata quarantine. Outcome. The durable flag now has exactly one writer symbol, `BodyFetchProcessor.markBodyMetadataOversized`, replacing three hand-copied UPDATE statements that had already begun to diverge. Its four callers are the singleton branch of `handlePayloadTooLarge` on both body queues, `BodyFetchProcessor.fetch`, and — new here — the inbox snippet loader's network tier, which previously observed an overflow and remembered it only in `snippetFailed`, a set `reloadMessages` clears on every `.inboxDataDidChange`. Tier 2 calls the same `provider.fetchMessage` the queues do and, because backfill admission is `date DESC`, is frequently the first path to reach a deep-history message; leaving its observations in process memory made the flagged population "whatever a background queue reached first", which is the one property this column must not have. Invariant impact. The writer carries a second guard, `AND id = accountId || ':' || folderPath || ':' || messageId`. During an `optimisticMoveToFolder` window a row's columns name the destination while its primary key still encodes the source folder and UID, so bytes fetched at the columns' address belong to a different message; an overflow observed there is not evidence about the row its key names, and is now refused rather than recorded. The network gate also moves to the funnel, `AccountManagerFetch.fetchBody`, so a future caller cannot open a hole by forgetting it; the checks in `MessageDetailViewModel` and the snippet loader remain but are demoted, in code and in comment, to deciding UI state and whether to keep polling. Also: the four bodyless diagnostic bucket predicates are hoisted to named constants so the partition can be asserted rather than assumed, and six test replicas of the queue admission predicate now run the production `Active`/`BackfillBodyQueue.admissionSQL` — those replicas stopped being the admission predicate the moment it gained `AND bodyMetadataOversized = 0`, so assertions claiming to describe the repopulate query no longer did. Verification. Full suite on a dedicated simulator and derived-data directory: 9,388 tests in 1,260 suites passed, one pre-existing known issue (`SyncFolderEpochPersistenceTests`, unchanged), no test-host restarts, no actionable compiler warnings. Seven tests added, all confirmed run by name: mid-move misattribution refusal, snippet-update flag clear, the bodyless-bucket partition under the quarantined-and- previously-empty overlap, the funnel's refusal, the snippet loader recording its observation, its non-overflow control, and the NSE body-merge clearing a recorded observation. Limitations. None accepted. This still does not make an oversized body fetchable; that remains the upstream parser fix. Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Round-4 audit fixes. Four angles ran on fresh-context specialists; architecture came back clean, the other three did not. Outcome. The stop-gap's headline symptom — a 2s poll paying a full TCP + TLS + LOGIN + SELECT for as long as the message is open — had a state left open that no gate could see. Both `bodyComplete` terms are deliberate: `markBodyMetadataOversized` guards `AND bodyComplete = 0` so a completed row cannot acquire a stale flag, and `isBodyQuarantined` carries `&& !bodyComplete` so an evicted-but- fetched row can still recover. Together they make a row that was fetched once, whose `messageBody` `BodyAssetMaintenance` later evicted while leaving `bodyComplete = 1`, and whose re-fetch now overflows, invisible at every site: nothing records it and nothing gates on it, so it polled forever. New `BodyFetchRefusal` gives the funnel's four refusal classes an identity; `loadBody` no longer starts a poll behind one that nothing will retract, and the poll's own catch ends on one. Pull-to-refresh is untouched and still reaches the wire. Invariant impact. All four durable marks now share ONE serialized write chain with the UIDVALIDITY reset's clear. Previously the two non-queue writers wrote directly, so a mark enqueued before a folder turnover could commit after the reset's clear and the resync, and quarantine a fresh-epoch row that reused the UID — passing both guards, because that row genuinely lives at that address and has no body yet. Separately, `enqueueDurableWrite` now captures its pool before the task rather than resolving `AppDatabase.shared` after the await; in production that is a no-op, in tests it stops a write that outlives its suite from clearing a sibling suite's fixture. `SyncEngineFTS.oneTimeBodyCompleteRestore` now clears the flag in the same statement that sets `bodyComplete = 1`, which is what makes the CLEARED enumeration true rather than nearly true. Hygiene. The one added log line that printed an untruncated header id (and with it a user-authored folder name) now truncates like every other; the funnel's entry log, ungated since before this branch, is debug-gated; `StuckMessageDiagnostics.countForTesting` — the one test seam that interpolates into SQL — is `#if DEBUG`. Verification. Full suite on a dedicated simulator and derived-data directory. A focused mutation run proved red-first for the round-3 invariants and, in doing so, caught one of my own tests being vacuous: the bodyless-bucket partition stayed GREEN when the pending predicate lost its quarantine conjunct, because its single fixture row had `emptyFetchCount = 2` and failed `emptyFetchCount = 0` either way. It now seeds two quarantined rows, with and without strikes, so both subtractions are exercised. Six more hand-copied admission predicates in tests — including three carrying never-drop claims — now run the production query, having silently stopped being that predicate when it gained `AND bodyMetadataOversized = 0`. Limitations. One newly enumerated, in the source beside the mechanism: expanding a collapsed thread bubble whose message is flagged now yields nothing rather than a lucky re-roll on different fragmentation. That function has always swallowed every failure, so the visible outcome is unchanged; the recovery is to open the message directly, where pull-to-refresh is exempt at the funnel. Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Fifth-round audit corrections to the oversized-metadata quarantine. No change to the stop-gap's behaviour except the two new release/ordering pins below; the rest is one deduplication, four retracted claims, and the coverage the audit found missing. Code: - BodyFetchProcessor.clearBodyMetadataOversized is the single clear-side writer, matching the single mark-side writer. Both queues' identical clearOversizedDurably SQL now calls it. The comment that had justified the duplication asserted a shared helper was impossible; that was false, and it is replaced by the real reasons the write CHAINS stay per-queue (per-instance test isolation against temp pools, and different priority tiers). - Two stale stacked doc blocks removed from BodyFetchProcessor: a leftover description above markBodyMetadataOversized and a self-contradicting one above markOversizedDurably. - The diagnostic MoveTrace print on the fetchBody address-corroboration path is now debug-gated. Retracted or scoped claims, all of which had walked reviewers past the real shape of the mechanism: - "All four marks share this one serialized chain" was too strong. BackfillBodyQueue marks through Backfill's own chain; the invariant that holds is that every mark shares a chain with the clear issued on that SAME chain, and the UIDVALIDITY reset clears on both. - The "one remaining bodyComplete = 1 writer" absolute now carries its negative case: v31 and v57 also write that column, excluded because they precede v88 and are frozen. - The release bound is first-party IMAPFetchMapping.responseBufferLimit, not an upstream constant, so raising it needs no upstream work. - The refusal strings are a DEBUG surface: MessageCardView renders the error only under DebugModeManager.isLoggingEnabled(), and a release user sees the generic retry copy. ProviderError.networkError's prefix is documented as inherited and deliberately not unwrapped. - The poll's fresh-read quarantine gate no longer claims the catch below only logs and continues; its two residual reasons are stated instead. Residuals, registered as IOS-BODY-006 with class open (not accepted) and documented at the mechanisms themselves: - The flag is a one-strike latch on a fragmentation-dependent signal, with no strike counter and no automatic expiry. Recovery is one user gesture. - SyncEngine.resetCrawlState and markOversizedDurably's non-queue callers write outside the serialized chain, so a mark dispatched just before Smart Reindex can commit after its clear. Fails closed, one dispatch wide, recoverable by repeating the gesture. Tests, four new invariants each proved red-first with a control green in the same run and the mutated file restored by hash against a pre-mutation backup: - the one-time bodyComplete restore releases the quarantine on the rows it heals and only those (two-sided against a skipped cached-HTML row); - a running poll ends on an overflow the durable flag structurally cannot see, with a control proving it survives an ordinary failure on the identical row; - end to end, a quarantined row lets an account reach Sync Complete while an identical unflagged row does not; - a non-queue mark is ordered against the reset's clear, with a non-vacuity control and a settle window. Plus: the bodyless diagnostic buckets now seed all five buckets rather than one, which is what makes the partition assertion able to fail; three PayloadTooLarge retryability tests drain the durable-write chain before reading; a force-unwrap became try #require per testing rule 9. Verification: 9,398 tests in 1,260 suites passed, one pre-existing known issue (SyncFolderEpochPersistenceTests, unchanged by this branch), no test-host restarts. The static @test census is 9,398, equal to the number executed, so the run is not truncated. Fresh full compile shows three warnings, all the documented-benign appintentsmetadataprocessor diagnostic, and nothing else. Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Sixth-round audit corrections. Two of the four angles came back clean; these address the reuse findings from architecture and the coverage gaps from test coverage. Deduplication, which is what this commit is mostly for: - BodyFetchProcessor.DurableWriteChain replaces 63 BYTE-IDENTICAL lines on each body queue. The two queues still hold their own INSTANCE — that is load-bearing for test isolation (suites construct their own queues against temp pools) and for the two write-priority tiers — but they no longer hold their own COPY. The block carried an "edit both copies or neither" comment over an ordering the file itself labels a correctness requirement, so a hardening applied to one queue would have silently left the other with the old semantics. The pool is now a parameter rather than a captured property, which makes the eager-resolution rule structural instead of a comment. - The ~90-line accepted-limitations enumeration was duplicated verbatim on both queues' markOversizedDurably under the same instruction. It lives once, on the single writer both of them call, with a pointer from each queue. - BodyFetchRefusal.addressInFlight is deleted. It carried a byte-identical copy of ProviderError.addressPendingMove's user-facing sentence, and fetchAttachment in the same file already threw the typed case that ComposeView matches BY TYPE. The funnel now throws it too. Behaviour is unchanged at both fetchBody call sites: fetchBodyWithRetry retries only messageNotFound and isConnectionError, and neither matches the typed case nor the NSError encoding it replaces. The endsPolling exclusion becomes structural rather than a listed omission. Coverage, each proved red-first with a control green in the same run and the mutated file restored by hash against a pre-mutation backup: - The durable clear's (accountId, folderPath) scoping had no test that could go red — every fixture in the tree is single-account, single-folder, and the three tests whose names promise scoping assert only the in-memory set, which is a different predicate. Dropping either conjunct survived all 9,398 tests. The new test seeds one bystander per conjunct, so each term is pinned separately; two mutations, each reddening exactly its own bystander. - The funnel's FRESH re-read was unpinned: every funnel test passed a header already matching its row, so trusting the caller's copy survived. The caller this protects is loadThreadMessageBody, which holds headers of arbitrary age. - endsPolling's membership was unpinned. Both poll controls throw messageNotFound, which is not a networkError and exits at the first guard, so widening the code comparison to include `retryable` left every poll test green while deleting the poll's entire transient-recovery role. The new test walks all six classes, isolating each guard. Comment corrections, all of them stale enumerations rather than wrong mechanisms: - The "census finds only THREE" count was correct when written and went stale when round 4 added a fifth clear; it now states the property (exactly one clear is invisible to SQL-text search) instead of an integer. - "Four surfaces move with that decision" omitted the two ftsIndexed arithmetic consumers, estimatedSecondsRemaining and updateRate's EMA; it now states the property and accepts the small ETA consequence explicitly. - "Five consumers, three of them code" omitted the funnel — the authoritative gate — on the code side and the diagnostics predicates on the SQL side. Both integers are gone. - selfHealBackfillFTSMembership still promised "body queue's next dispatch will succeed", which stopped being true for the flagged subset the paragraph below it deliberately keeps in scope. - The snippet loader's overflow classification records that it is the 14th hand-copy of that substring test, and that the canonical symbol arrives on the branch for #103; a competing local helper is deliberately not minted, because that would leave two predicates to reconcile at merge instead of one. Verification: 9,401 tests in 1,260 suites passed, one pre-existing known issue (SyncFolderEpochPersistenceTests, unchanged by this branch), no test-host restarts. Static @test census is 9,401, equal to the number executed. Fresh full compile: three warnings, all the documented-benign appintentsmetadataprocessor diagnostic, nothing else. Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
… typed refusal Round-7 audit (four fresh-context Claude specialists on 690b944) returned architecture, correctness and robustness-security CLEAN with comment-level findings only, and test-coverage UNCLEAN on two missing tests. This applies all of them. The one that matters most is a status claim. Three source sites headed the accepted-limitations enumeration "owner-blessed", while the register this same branch adds — IOS-BODY-006 — says the opposite: filed `open`, NOT `accepted`. That is the exact shape the no-accepted-limitation-without-owner-blessing rule exists to prevent: a future reader greps the source, reads "owner-blessed", and stops asking the question the record was filed to raise. The headers now name the register, state that exactly TWO items carry an owner decision (item 2, let "Sync Complete" fire; item 4's fail-fast-on-open, both 2026-09-01), and say plainly that items 1, 3, 5, 6 and 7 do not. Item 4 gained the dated marker it was missing, and the register's blanket "none has the owner's blessing yet" now exempts the two that do. Also swept: a stranded "Edit both copies or neither" instruction that survived the hoist which deleted the second copy; two pointers (the register and the KNOWN_ISSUES row) still aiming at both queues' markOversizedDurably instead of the single writer; a "codes -1/-2/-4 have always been wrapped this way" that is false for -4, which this branch introduces; and a complement claim on bodySettledRequest that reads as a lockstep invariant a maintainer would "restore" — the request is deliberately not scoped to headerComplete, the asymmetry is inherited, and either edit would move published progress numbers. AppDatabase gained three records a diff cannot show: the EXPLAIN QUERY PLAN figures now state the statistics regime they were taken under (empty/no sqlite_stat1 row, the shipped fresh-install regime; the fresh-statistics side is unmeasured), as IOS-PERF-012 requires; an earlier in-branch revision built messageHeader_bodyRepopulateV2 INSIDE v88 and it moved to deferredIndexes at 436c7c3, so dev databases that ran the old body already carry the index and converge with a fresh install because createDeferredIndexes is IF NOT EXISTS; and the v88 number collides with PR #103, which breaks no migration but does break this file's own equal-counts self-check after a merge (21 distinct numbers vs 22 .immediate lines), so whichever branch lands second must renumber and wipe every dev DB that ran the old name. Tests. Two gaps, both proven by mutation before being closed: - The funnel's mid-move refusal had no producer-side test. The classification test constructs the error by hand and the predicate test never runs the funnel, so reverting the throw to the pre-change wrapped NSError left the whole suite green while isConnectionError started returning true for it — a wasted retry and a "check your connection" message for a refusal that has nothing to do with the network. M17 reproduces exactly that revert and the new test goes red while the classification test stays green in the same run. - The partition test seeded a single account, so the accountId conjunct was satisfied vacuously and could be deleted from either request with the whole suite green. A second account is now seeded with one pending-shaped and one settled-shaped row; M18a and M18b drop the conjunct from each request in turn and each goes red on its own bystander. Verification: full suite green on a fresh compile of the app targets, run on a dedicated simulator and derivedData directory. Three mutations red-first with controls green in the same invocation and every restore verified by SHA-256 against a pre-mutation backup rather than by git status, which is invalid in a tree carrying uncommitted fixes. Accepted limitations: unchanged, and still awaiting the owner's decision on the five that have not had one. Nothing here reclassifies IOS-BODY-006. Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Round-8 test coverage found the last vacuously-satisfied term in this branch. `MessageHeader.pendingBodyRequest` carries `headerComplete == true` and `bodySettledRequest` deliberately omits it, and NOTHING pinned either fact: every fixture that measured either request set `headerComplete = 1` by construction, so both of the opposite edits left all 9,402 tests green. That was measured, not argued. Deleting the conjunct from pendingBodyRequest and adding it to bodySettledRequest were each run against the FULL suite — a targeted run answers "does my new test catch it" but not "does anything else", and the second question is the finding. Each mutation reddened exactly one test of 9,403, the new one, on the matching assertion. The distinguishing rows are reachable rather than hypothetical. NSEDataBridge stages headers with headerComplete = false and flips the flag in a separate statement, so an extension wake terminated between the staging write and the flush leaves such a row behind durably. Both consequences re-create the failure this branch exists to remove, from opposite sides. Without the conjunct on pending, a staged row enters pendingBodyCount and BackfillProgress.isFullyComplete is never true — the sync banner never clears and Fast Sync holds the screen awake indefinitely. With the conjunct added to settled, staged-but-settled rows leave the ftsIndexed numerator while totalEmails still counts them, so "N / M indexed" parks permanently below its denominator. One test pins both, each assertion paired with a header-complete control of the same shape so neither can pass on an empty result. Also corrected: two funnel tests explained their own safety with the wrong mechanism. They said no provider is registered, so the call cannot reach the wire. That is backwards — the quarantine gate sits before the provider block, so an absent provider is what causes connectAccount to be CALLED. What actually keeps them offline is that the fixture's account has no imapHost, which makes createIMAPProvider throw before any provider is built, registered in the process-wide AccountManager.shared, or asked to connect. The tests were safe, but for an unstated reason that a plausible future edit — adding host and port to that fixture — would silently remove, turning them into live connection attempts from the unit suite. Both comments now name the real guard and both fixtures assert it. Production code is untouched: `git diff HEAD -- TabMail/` is empty, so the shipped bytes are identical to the previous commit. Verification: full suite green on a fresh compile, dedicated simulator and derivedData — 9,403 tests in 1,260 suites, one pre-existing known issue, zero source-located compiler errors or warnings, and only the three documented-benign AppIntents diagnostics. Both mutations restored and verified by SHA-256. Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Round-9 test coverage found that the branch's headline user-visible behaviour was reachable only through a mapping no test constrained. `fetchBody`'s tail turns a BodyFetchProcessor outcome into a refusal — `.payloadTooLarge` becomes a terminal class, `.retry` a retryable one — and both `loadBody` and the 2s body poll branch on exactly that distinction via `BodyFetchRefusal.endsPolling`. Both halves were tested and the join was not. The classifier test walks six classes but constructs every refusal by hand; every MessageDetailViewModel test injects `_fetchBodyOverride`; and all six other test call sites of `fetchBody` exit before this tail, at the address gate, the quarantine gate, or createIMAPProvider's missing-imapHost guard. Measured, not argued: swapping the two arms left all 9,403 tests green. That swap restores the exact defect this branch exists to remove — an oversized body classifies as retryable, `loadBody` starts the poll, and the row is re-fetched every two seconds forever, each tick a full TCP + TLS + LOGIN + SELECT plus a folder connection teardown. The poll's own quarantine gate is no help in the eviction case: an evicted row reads bodyComplete = 1, so isBodyQuarantined is false. The new test drives the funnel to completion against a registered mock provider, which is what lets it reach the tail at all — a registered provider means connectAccount is never called, so the host-less fixture account is not consulted. Two legs in one run against two separate rows, because the overflow leg durably flags its own row and reusing it would turn the control's refusal into a quarantine rather than a retry. Under the swap both legs go red. It asserts the CLASSIFICATION and never the integer code. Pinning -1/-2 would be a mechanism-pinning test, which inherits a wrong spec's error and stays green on a broken system. Production code is untouched: `git diff HEAD -- TabMail/` is empty. Verification: full suite green on a fresh compile, dedicated simulator and derivedData — 9,404 tests in 1,260 suites, one pre-existing known issue, zero source-located compiler errors or warnings, only the three documented-benign AppIntents diagnostics. Anchored @test census is 9,404, matching the executed count exactly. The mutation was restored and verified by SHA-256. Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #104. Companion to #74, which stays open as the tracker for the real (upstream parser) fix — do not close #74 on this.
This does not make an oversized body fetchable. It stops the app paying for a fetch that cannot succeed, and stops it asking the user to wait for one.
The bug
handlePayloadTooLargequarantines an oversized message inoversizedDeferredThisSession, an in-memory, process-lifetime set. Every launch rebuilds it empty, so every affected message is offered to the body queues again, fails again, and is quarantined again. Each failure is expensive:withFolderConnectionclassifiesPayloadTooLargeErroras unhealthy and releases the connection, so the retry pays a full TCP + TLS + LOGIN + SELECT.Consequences: the body is never indexed;
BackfillProgress.pendingBodyCountnever reaches 0, so the sync banner never clears and the progress bar parks short of 100%; and opening the message spends a connection to land on "Unable to load message", with a 2s poll repeating it.Correcting the premise the old comments carried
Several comments described the overflow as size-deterministic per binary. It is not — the parser's bound is on unread aggregate bytes measured after the decode loop stops, so it depends on wire fragmentation. The same message can overflow on a lossy link and parse fine on WiFi.
So an overflow is an observation about one wire attempt, never a verdict that the body is unfetchable, and nothing here treats it as one.
The change
A durable
messageHeader.bodyMetadataOversizedflag.v88_addBodyMetadataOversizedadds only the column. The supporting indexmessageHeader_bodyRepopulateV2is built off the blocking launch path bySyncEngine.deferredIndexes, per ADR-IOS-029's 2026-08-05 amendment — its absence degrades performance and nothing else, which is that ADR's own eligibility test, andv83_markAllAsReadUnreadSweepIndexis the precedent (its body is intentionally empty for exactly this reason).BodyFetchProcessor.markBodyMetadataOversized: the singleton branch ofhandlePayloadTooLargeon both queues,BodyFetchProcessor.fetch'sPayloadTooLargeErrorbranch, and the inbox snippet loader's network tier. That last one matters — it calls the sameprovider.fetchMessagethe queues do, and on a scrolling user it is frequently the first path to reach a deep-history message, since backfill admission isdate DESC. A tier that observed an overflow and only remembered it in process memory would make the flagged population "whatever a background queue happened to reach first", which is precisely what this column must not be. A multi-item overflow still isolates rather than flagging: the batch error does not say which item was too large.AND bodyComplete = 0keeps a completed row from being handed a stale flag.AND id = accountId || ':' || folderPath || ':' || messageIdrefuses to write inside an optimistic-move window —optimisticMoveToFolderrewrites a row's columns to the destination while its primary key still encodes the source folder and UID, so bytes fetched at the columns' address in that window belong to a different message, and an overflow observed there is not evidence about the row its key names.MessageHeader.isBodyQuarantined(bodyMetadataOversized && !bodyComplete) or, for the SQL half, on the single hoistedActive/BackfillBodyQueue.admissionSQL:repopulateFromDatabaseandrepopulateOnDrain, on each queueAccountManagerFetch.fetchBody— the funnel every on-demand fetch goes throughMessageDetailViewModel.loadBody(the user open)MessageDetailViewModel.startBodyPoll— the 2s body pollInboxViewModel.loadSnippetBatchtier 2Row 5 is the authoritative one: the network gate lives at the funnel, not only in the callers, so a future caller cannot open a hole by forgetting it. Rows 6–8 are not the guard — they decide which UI state to show and whether to keep polling. The poll and the snippet loader are the two initiators this change adds.
bodyCompleteterms are deliberate: the writer'sAND bodyComplete = 0stops a completed row acquiring a stale flag, andisBodyQuarantined's&& !bodyCompleteis the eviction fail-safe. Together they make one row invisible at every site — fetched once,messageBodylater evicted byBodyAssetMaintenance(which leavesbodyComplete = 1by design), re-fetch now overflows. Nothing records it; nothing gates on it; it polls forever.BodyFetchRefusalgives the funnel's four refusal classes an identity, andendsPollingis whatloadBodyand the poll's catch consult before continuing. It also collapses three hand-copied user-facing strings and four bareNSErrorcodes into one place. Both mattered:loadBody's cancelled-read exits all callstartBodyPoll(); returnbefore its quarantine branch, so a flagged row was retried every 2s indefinitely, each attempt paying a full TCP + TLS + LOGIN + SELECT; andreloadMessagesclearssnippetFailedand re-queues the visible window, so tier 2 re-attempted the samefetchMessagethat overflowed on every reload. The poll reads the flag fresh from the database each tick, because a background queue can flag the row while the poll is running.bodyCompleteandbodyEmptyConfirmedstay 0,emptyFetchCountandmissFetchCountare untouched, and the header stays FTS-indexed — the message is still findable by subject and sender.Two product decisions, made deliberately
Both reverse a non-change an earlier revision of this work had built and defended; the superseded reasoning is kept in the source and tests rather than deleted.
The sync banner is allowed to complete. Backfill progress counts a flagged row as settled, so
pendingBodyCountcan reach 0. A banner that can never clear, over work the build cannot perform, is worse than rounding an unfetchable message up to done. Theindexednumerator moves withpending, or the bar contradicts the check.Opening a flagged message reports failure immediately.
loadBodypresents the same state a failed fetch leaves behind — error shown, no spinner, header rendered — with no wire attempt and no poll. Pull-to-refresh is deliberately unchanged and still performs a genuine fetch, which is what keeps the flag an observation rather than a verdict.Releases
BodyAssetMaintenanceevicts amessageBodyrow while deliberately leavingbodyComplete = 1, and the detail view's cache-miss fetch is the only recovery. A stale flag would delete that recovery and brick a message already fetched once. The open path carries&& !msg.bodyCompleteas the fail-safe for any residual;SETand theWHERE, or it silently skips the rows the gesture was invoked for;UPDATE messageHeader SET bodyMetadataOversized = 0 WHERE bodyMetadataOversized = 1. Exact by construction, because every row carrying the flag was written by this code.That last point is why this earns a dedicated column rather than reusing an existing state: a reused value cannot name its own population.
Verification
Full suite green on a dedicated simulator and a dedicated derived-data directory, both created for this branch and deleted afterwards: 9,404 tests in 1,260 suites passed, one pre-existing known issue (
SyncFolderEpochPersistenceTests, unchanged by this branch), no test-host restarts, and no actionable compiler warnings — only the three documented-benignappintentsmetadataprocessorlines (one per target that runs the processor).The run is corroborated as complete rather than truncated: a static census of
@Testdeclarations inTabMailTests/returns 9,404, exactly the number executed. The same census on the base commit returns 9,337, so this branch adds 67 tests net. (The census must be anchored —^\s*@Test— because eight doc comments inTabMailTests/mention@Testin prose and an unanchored count reads 9,410, an eight-test phantom surplus. No test carries a.disabledtrait, so the identity is exact rather than a coincidence.) A truncated run cannot match its own census, which is the check that matters here — an earlier run in this work reported a plausible-looking 236 tests because the test host was crash-restarting on a polluted simulator container.Red-first proved by focused mutation for every new invariant — invert the fix and the test that pins it fails while its control stays green:
!msg.bodyCompletefrom the open pathbodyMetadataOversized = 0from theflushBatchsuccess writeresetCrawlState'sWHEREtobodyEmptyConfirmed = 1CREATE INDEXback inv88repopulateFromDatabaseawait previous?.valuefromenqueueDurableWritemarkBodyMetadataOversizedbodyMetadataOversized = 0from the NSE batch flip, bodyMetadataOversized = 0fromoneTimeBodyCompleteRestore's healif BodyFetchRefusal.endsPolling(error)→if falsein the poll's catchbodyMetadataOversizedconjunct from backfill progressAND m.bodyEmptyConfirmed = 0from the diagnostics predicateAND folderPath = ?from the durable clearAND accountId = ?from the durable clear|| ns.code == retryabletoendsPollingEvery mutation ran with a control test in the same run that had to stay green, so a mutation that simply broke the suite cannot be read as a red-first proof; and each mutated file was restored by hash against a pre-mutation backup, not by
git checkout.AND m.bodyMetadataOversized = 0from the diagnostics' pending predicate left the bucket-partition test GREEN: its single fixture row hademptyFetchCount = 2and failedemptyFetchCount = 0either way, so the mutation was invisible to it. It now seeds two quarantined rows — one with strikes, one without — because the four buckets subtract the quarantine from two different siblings and one fixture cannot exercise both.Six further hand-copied admission predicates in the test suite now run the production
Active/BackfillBodyQueue.admissionSQLinstead. Three of them carry never-drop claims — "a row that satisfies this is one the body fetch WILL pick up" — and had silently stopped being that predicate the moment production gainedAND bodyMetadataOversized = 0.Tests drive production symbols rather than replicas: the relaunch tests call the real
repopulateFromDatabase(), Smart Reindex calls the realSyncEngine.resetCrawlState(), the success-clears test calls the realBodyFetchProcessor.flushBatch. The query-plan gate asserts all five equality columns in the seek with no temp B-tree, against the deferred index, with a negative control.What the audit rounds changed
Ten rounds of four-angle review (architecture, correctness, robustness-security, test coverage) ran
against this branch, each angle on a fresh-context specialist in its own detached reviewer worktree.
The final round carried architecture, correctness and robustness-security forward as clean on a
machine-checked proof that the intervening source diff was comment-only (every added and removed
line in all six touched source files matches
^\s*(///|//)), and re-ran test coverage, whosesubstance had changed. The code changes they produced, and the claims they retracted:
BodyFetchProcessor.markBodyMetadataOversized; the clear was hand-copied into both queues'clearOversizedDurably. It is nowBodyFetchProcessor.clearBodyMetadataOversized, and bothqueues call it. The comment that justified the duplication claimed a shared helper was
impossible — that was false (
Mutex<Task<Void, Never>?>isSendableandwithLockissynchronous), and it is replaced by the two real reasons the write chains stay per-queue:
per-instance isolation, because suites construct their own
ActiveBodyQueue()/BackfillBodyQueue()against temporary pools, and different priority tiers (syncPoolvsbackgroundPool).BackfillBodyQueue'shandlePayloadTooLargemarks through Backfill's chain, not Active's. The invariant thatactually holds — and the one the ordering depends on — is that every mark shares a serialized
chain with the clear issued on that same chain, and the UIDVALIDITY reset clears on both.
bodyComplete = 1writer" absolute now carries its negative case.Migrations
v31_addHasBodyInFTSandv57_repairOptimisticSentBodyCompletealso write thatcolumn; they are excluded because they run before
v88and are frozen, not because they do notexist.
IMAPFetchMapping.responseBufferLimit(4 MiB) is ours — upstream PR #179 made it a constructor parameter — so raising it needs no
upstream work. Several comments had described it as an upstream constant.
MessageCardViewrendersviewModel.erroronlyunder
DebugModeManager.isLoggingEnabled(); a release user sees the generic"Unable to load message. Pull to retry." The refusal texts are documented as such rather than as
user-facing copy, and
ProviderError.networkError's"Network error: "prefix is documented asinherited and deliberately not unwrapped.
BodyFetchProcessor.DurableWriteChainreplaces 63 byte-identical lines on each queue,which had sat under an "edit both copies or neither" comment over an ordering the file
itself labels a correctness requirement. Each queue still holds its own INSTANCE — that
is load-bearing for test isolation and for the two write-priority tiers — but no longer
its own copy, and the database pool became a call parameter, which turns the
eager-resolution rule from a comment into a signature.
queues under the same instruction. It lives once now, on the single writer both call.
BodyFetchRefusal.addressInFlightis gone. It carried a byte-identical copy ofProviderError.addressPendingMove's user-facing sentence, whilefetchAttachment— inthe same file — already threw the typed case that
ComposeViewmatches by TYPE. Thefunnel throws it too now. Behaviour is unchanged at both
fetchBodycall sites, andendsPolling's exclusion of the mid-move refusal becomes structural rather than alisted omission.
had been correct when written and had gone stale as the branch grew: a census count of
"three" that returned four, "four surfaces" that omitted two
ftsIndexedconsumers,"five consumers, three of them code" that omitted the authoritative funnel, and a
self-heal comment still promising a next dispatch that the quarantine had removed.
the accepted-limitations enumeration "owner-blessed" while
IOS-BODY-006says the opposite —filed
open, NOTaccepted. That gap is the whole failure mode the register exists to prevent:a reader greps the source, reads "owner-blessed", and stops asking the question. The headers now
name the register and state that exactly two items carry an owner decision (let "Sync
Complete" fire; fail fast when an affected message is opened — both 2026-09-01), and that the
other five do not. The register's own blanket "none has the owner's blessing yet" now exempts
the two that do.
EXPLAIN QUERY PLANfigures now state the statistics regime they were measured under, which
IOS-PERF-012requiresand which decides whether the numbers mean anything; an earlier revision on this branch built
the deferred index inside
v88before it moved todeferredIndexes, so a dev database that ranthe old body already carries the index and converges with a fresh install only because
createDeferredIndexesisIF NOT EXISTS; and the migration number collides with the siblingbranch, which breaks no migration but does break this file's own equal-counts self-check after a
merge.
error by hand and the predicate test never runs the funnel, so reverting the throw to the
pre-change wrapped
NSErrorleft the entire suite green while the refusal started classifying asa connection error — a wasted retry and a "check your connection" message for something that has
nothing to do with the network. Both halves green, contract dead. A mutation reproducing exactly
that revert now turns the new test red while its control stays green in the same run.
are now pinned. Every fixture in the partition suite seeded one account, so
accountIdcould bedeleted from either request with the whole suite green; a second account is now seeded with one
pending-shaped and one settled-shaped row, and two mutations drop the conjunct from each request
in turn. The second was
headerComplete, whichpendingBodyRequestcarries andbodySettledRequestdeliberately omits — every fixture that measured either request set it byconstruction, so both of the opposite edits left all 9,402 tests green. That one was proved
against the full suite rather than a targeted subset, because the claim under test was not
"does my new test catch it" but "does anything else": each mutation reddened exactly one test of
9,403, the new one. The rows that distinguish it are reachable —
NSEDataBridgestages headerswith
headerComplete = falseand flips the flag in a separate statement, so an extension waketerminated in between leaves one behind. Dropping the term from pending re-creates the
never-clearing banner and pinned wake lock this branch exists to remove; adding it to settled
parks "N / M indexed" permanently below its denominator.
registered, so the call cannot reach the wire — backwards, since the quarantine gate sits before
the provider block and an absent provider is what causes
connectAccountto be called. Whatactually keeps them offline is that the fixture's account has no
imapHost, socreateIMAPProviderthrows before any provider is constructed, registered in the process-widesingleton, or asked to connect. The tests were safe for an unstated reason that one plausible
edit — adding host and port to that fixture — would have removed silently, turning them into live
connection attempts from the unit suite. Both comments now name the real guard and both fixtures
assert it.
behaviour.
fetchBody's tail turns a processor outcome into a refusal —.payloadTooLargeto aterminal class,
.retryto a retryable one — and bothloadBodyand the 2s poll branch on exactlythat distinction. Both halves were tested and the join was not: the classifier test constructs
every refusal by hand, every
MessageDetailViewModeltest injects_fetchBodyOverride, and allsix other test call sites of
fetchBodyexit before the tail. Swapping the two arms left all9,403 tests green while restoring the original defect in full — an oversized body would classify
as retryable, the poll would start, and the row would be re-fetched every two seconds forever. The
poll's own quarantine gate cannot save it in the eviction case, where the row reads
bodyComplete = 1. A new test now drives the funnel to completion against a registered mockprovider; under the swap both of its legs go red. It asserts the classification, never the
integer code, since pinning
-1/-2would be a mechanism-pinning test.Accepted residuals —
IOS-BODY-006, filedopenRegistered in the known-issues register (post-freeze amendment channel) and documented at the
mechanisms themselves, not only in the register:
attempt quarantines the row until something positively releases it; there is no strike counter
and no automatic expiry. Because the parser bound is on unread aggregate bytes measured after
the decode loop stops, a message that overflows on a lossy link can parse fine on WiFi — so a
single unlucky attempt can quarantine a message this build could have fetched. The recovery is
one ordinary user gesture (pull-to-refresh on the open message, or Smart Reindex), which is why
this is filed as a residual rather than built around.
SyncEngine.resetCrawlState(Smart Reindex) writes through
AppDatabase.backgroundPool, andmarkOversizedDurably'snon-queue callers are ordered against the reset's clear only by that same chain. A mark
dispatched just before a Smart Reindex can therefore commit after its clear, leaving a row
quarantined that the gesture was invoked to release. It fails closed — the row is retryable
by the same gesture, repeated — per THE MANTRA, and the window is one dispatch wide.
Both are filed with class
open, notaccepted: they need the owner's decision, and this PR doesnot assert one.
The full decision being asked for. Seven limitations are enumerated on
BodyFetchProcessor.markBodyMetadataOversized. Two already carry an owner decision, both dated2026-09-01 and both reversals of an earlier stance in this branch: let "Sync Complete" fire on an
account that still holds an unfetchable body, and fail fast when such a message is opened rather
than spending a doomed wire attempt. The other five do not, and are what this PR puts in front
of you:
IMAPFetchMapping.responseBufferLimitis raised(the header stays FTS-indexed, so the message is still findable by subject and sender);
this row's header is healthy;
wire attempt — with recovery by opening it as the focused message;
Until you rule, the source says
openand notacceptedin every place it mentions them, and anearlier revision of this branch that called the set "owner-blessed" was corrected by the audit.
Merge order
v88— herev88_addBodyMetadataOversized, therev88_addBodyIndexingFailureReason. They are merge-exclusive by construction, not conflicting in substance. This one merges first; #103 renumbers tov89when it resumes. Verified: #103 does not yet carry that prerequisite in its body or comments, so it is being noted there separately.One unrelated commit rides along
Wait for the fake IMAP transport to settle before asserting no live sessionfixes a pre-existing race in the test harness, not in the app.assertIMAPTeardownreadFakeIMAPServer.liveSessionCount()the instantprovider.disconnect()returned, but disconnect returns when the client closes its socket while the server drops the fd on its own thread when it observes that close. Instrumented on a failing run: the count read 1, healed to 0 after 50ms, andabandonedSessionCount()— the monotonic oracle that never heals — was 0, so nothing had actually been abandoned. The assertion now waits for the transport to settle, bounded at 2s, and still fails on a session genuinely left live.