Skip to content

fix(activesync): separate PING watermark from SYNC modseq to stop iOS…#41

Merged
TDannhauer merged 9 commits into
FRAMEWORK_6_0from
fix/ActiveSync_loops
Jun 9, 2026
Merged

fix(activesync): separate PING watermark from SYNC modseq to stop iOS…#41
TDannhauer merged 9 commits into
FRAMEWORK_6_0from
fix/ActiveSync_loops

Conversation

@TDannhauer

@TDannhauer TDannhauer commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Fix ActiveSync PING/SYNC loops, state corruption, and initial-sync reliability

Branch: fix/ActiveSync_loopsFRAMEWORK_6_0
Repository: horde/ActiveSync
HEAD: 22a44863 (8 commits, 22 files, +3181 / −221 lines)


Summary

This branch fixes several interacting ActiveSync 3.0 regressions that caused iOS PING loops, Invalid sync_data / KEYMISMATCH cycles, stuck sync_pending (MOREAVAILABLE) batches, and missing mail on initial sync. The changes introduce a clearer separation between SYNC and PING watermarks, harden state persistence against concurrent workers, validate collection state on load/save, and improve MIME export for nested/signed messages.

Production testing on Horde 6 / activesync 3.0.0-beta3 reports significantly improved stability after these fixes.


Problems addressed

1. iOS PING loops (~3s wake/sleep cycle)

Email collections used a single modseq for both:

  • SYNC (CHANGEDSINCE / $_status)
  • PING (IMAP STATUS polling)

Additionally, PING treated sync_pending (in-flight MOREAVAILABLE batches) as a change signal. When SYNC modseq lagged behind the server, or a partial batch remained in sync_pending, PING would wake repeatedly without delivering mail.

2. State corruption under concurrency

Parallel SYNC and PING workers could load and save the same sync_key concurrently, overwriting sync_data and sync_pending. This produced Invalid sync_data corruption, especially during heavy initial sync on large mailboxes.

3. Corrupt sync_data written or silently accepted

Empty arrays (a:0:{}), non-folder blobs, and FOLDERSYNC-shaped data could be treated as valid collection state, breaking subsequent PING/SYNC and blocking new-mail push.

4. Initial sync and MIME export gaps

  • Initial sync could be marked complete before exported UIDs were acknowledged.
  • Failed exports could advance progress or drop sync_pending entries on retriable backend errors.
  • Deeply nested MIME (e.g. inline PGP keys on Dovecot) could fail body fetch and silently omit messages.

Solution

PING / SYNC watermark separation

Introduce a three-state model for mail collections:

State Purpose
$_status SYNC watermark (CHANGEDSINCE)
$_pingStatus PING watermark (IMAP STATUS via pingModseq())
sync_pending SYNC-only; MOREAVAILABLE batch tracking

PING now:

  • Polls IMAP STATUS only (does not use sync_pending as a change signal)
  • Advances $_pingStatus on detection
  • Saves with preservePending => true so in-flight MOREAVAILABLE batches survive PING saves

Files: State/Base.php, Folder/Imap.php, Imap/Adapter.php

Concurrency control

Row locks (device + sync_key level):

  • SQL: SELECT … FOR UPDATE held from load through save() / updateSyncStamp()
  • Mongo: sync_lock field with findAndModify(), including stale-lock recovery

Collection locks (per folder):

  • Migration 24: horde_activesync_collection_lock (SQL) / HAS_collection_lock (Mongo)
  • Acquired around loadState(), save(), and updateServerIdInState()
  • Row locks remain held through save()

Files: State/Sql.php, State/Mongo.php, migrations/24_horde_activesync_addcollectionlock.php

State validation and recovery

  • _normalizeSyncFolderData() — missing/unparseable blobs are rebuilt; clearly corrupt payloads raise StaleState
  • _assertValidSyncFolderBeforeSave() / _assertSyncDataBlob() — refuse to persist invalid folder objects or empty/array-shaped blobs
  • KEYMISMATCH on corrupt load — corrupt sync_data forces a collection resync via STATUS_KEYMISMATCH instead of silently reinitializing at the same synckey

Files: State/Base.php, Collections.php, Request/Sync.php

Safer persistence (SQL / Mongo)

  • Scope UPDATE/INSERT to sync_key, sync_folderid, sync_devid, sync_user
  • Persist sync_pending as TEXT; only sync_data uses Horde_Db_Value_Binary
  • Wrap persist in transactions; retry concurrent INSERT as UPDATE

Initial sync and export reliability

  • Defer marking initial sync complete until exported UIDs are acknowledged
  • Fix haveInitialSync=false being treated as complete when the hi key is present
  • Stop SYNC export loop from treating failed exports as progress
  • Retry IMAP body fetch without BODY[].SIZE when nested parts return no data (Dovecot)
  • Treat application/pgp-keys like S/MIME signatures for attachment detection
  • Log message build failures instead of silently omitting messages

Files: Connector/Exporter/Sync.php, Folder/Imap.php, Imap/MessageBodyData.php, Mime.php, Mime/Iterator.php, Request/Sync.php


Commits (oldest → newest)

Commit Description
ba01d163 Separate PING watermark from SYNC modseq; preservePending on PING save
d5038717 Reject corrupt sync_data on load and save
b1c288f5 Initial sync acknowledgement, export loop fixes, nested MIME / PGP-key export
e480db3a SQL and Mongo row locks for parallel state access
c7e372fb Doc clarifications (row lock behavior, sendNextChange contract)
0dfff8d2 Collection locks, scoped state writes, corrupt-state recovery
161d301e Migration filename typo fix
22a44863 Force KEYMISMATCH when loading clearly corrupt sync_data

Database migration

Run Horde migrations after merge:

php /path/to/horde/bin/horde-upgrade activesync

Adds collection-lock storage required for per-folder serialization.


Tests

New/expanded unit tests under test/Horde/ActiveSync/StateTest/:

Test Coverage
StateSqlPreservePendingTest PING saves preserve sync_pending; watermark separation
Sql/RowLockTest, Mongo/RowLockTest Concurrent load/save serialization
Sql/CollectionLockTest, Mongo/CollectionLockTest Per-collection lock acquire/release
Sql/InitialSyncTest Initial sync acknowledgement
ImapFolderTest, ImapAdapterTest PING checkpoint / modseq behavior
MessageBodyDataTest, MimeTest Nested MIME body fetch fallback, PGP-key structures
cd vendor/horde/activesync   # or package root
vendor/bin/phpunit test/Horde/ActiveSync/StateTest/
vendor/bin/phpunit test/Horde/ActiveSync/ImapFolderTest.php
vendor/bin/phpunit test/Horde/ActiveSync/MessageBodyDataTest.php

Production validation

Tested on Horde 6 deployment with:

  • iOS Mail (EAS 16.0) — PING loops resolved; INBOX synckeys advance cleanly
  • PostgreSQL backend — no recurring Invalid sync_data after fixes + PHP-FPM reload
  • Tester feedback: “quite stable” under normal use

Related work (not in this branch)

PostgreSQL deployments may also need a complementary horde/db fix for PDO multi-LOB binding: when sync_data and sync_pending are bound as LOBs in a single execute(), PostgreSQL bytea can be corrupted. That amplifies the state bugs fixed here. Track separately in horde/Db.

Window size configuration (maximumwindowsize / maximumrequestwindowsize = 0) is orthogonal but increases batch size and MOREAVAILABLE pressure. Recommend finite values (25–100) in production conf.php regardless of this merge.


Test plan

  • Run full StateTest suite on SQL (PostgreSQL/MySQL) and Mongo backends
  • PING loop: Large INBOX, new mail arrives → single PING wakeup → SYNC delivers mail; no ~3s PING loop
  • MOREAVAILABLE: Force large initial sync → verify sync_pending clears across multiple SYNC rounds; PING does not false-wake on pending batch
  • Concurrency: Parallel SYNC + PING on same device/collection → no Invalid sync_data in logs
  • Corrupt state recovery: Inject corrupt sync_data row → client receives STATUS_KEYMISMATCH and resyncs cleanly
  • Signed/nested mail: Message with inline PGP key exports and appears on device
  • Migration 24: Upgrade path creates collection-lock table/collection on fresh and existing installs
  • Regression: Small-folder sync, calendar/contacts PING unaffected

Deployment notes

  • Requires horde-db-migration before relying on collection locks
  • Reload PHP-FPM/Apache after deploy to clear opcode cache
  • Monitor device logs for Invalid sync_data, KEYMISMATCH, and COLLECTIONS: Found changes! without subsequent SYNC

TDannhauer and others added 5 commits June 8, 2026 08:31
… mail loops

x(activesync): stop iOS PING loops with separate SYNC and PING watermarks
Email collections used one modseq for both SYNC (CHANGEDSINCE) and PING
(IMAP STATUS), and PING treated sync_pending as a change signal. That
caused infinite ~3s PING loops when SYNC modseq lagged behind the server,
and when a MOREAVAILABLE batch remained in sync_pending.
Introduce a three-state model: SYNC watermark ($_status), PING watermark
($_pingStatus / ps in sync_data), and sync_pending (SYNC only). PING now
polls IMAP STATUS via pingModseq(), advances the checkpoint on detection,
and saves with preservePending so in-flight MOREAVAILABLE batches survive.
Add unit tests for checkpoint serialization, PING-vs-SYNC modseq separation,
and preservePending saves.
fix(activesync): reject corrupt collection sync_data on load and save

Treat deserialized empty arrays and non-Folder_Base blobs as missing state
instead of using them as folder objects. Refuse save() when _folder is not a
valid Folder_Base instance to prevent writing a:0:{} into horde_activesync_state.
…export

fix(activesync): repair initial sync, corrupt state, and nested MIME export

Follow-up to the PING/SYNC watermark split. Several bugs still blocked
reliable mail delivery on iOS ActiveSync:

- Reject empty or non-folder sync_data on load and refuse to persist
  invalid collection state on save, preventing corrupted rows from
  breaking PING/SYNC and new-mail push.
- Defer marking initial sync complete until exported UIDs are
  acknowledged; fix unserialize treating haveInitialSync=false as
  complete when the hi key is present.
- Stop the SYNC export loop from treating failed exports as progress
  and from dropping sync_pending entries on retriable backend errors.
- Retry IMAP body fetches without BODY[].SIZE when the server returns
  no data for deeply nested MIME parts (Dovecot), so signed messages
  with inline PGP key material export correctly.
- Treat application/pgp-keys like S/MIME signatures for attachment
  detection and log message build failures instead of silently
  omitting messages from export.

Add unit tests for initial-sync acknowledgement, corrupt state
handling, preservePending saves, nested MIME body fetch fallback,
and PGP-key MIME structures.
…ow locks

Parallel SYNC and PING workers could load and save the same sync_key
concurrently, overwriting sync_data and sync_pending and causing Invalid
sync_data corruption during heavy initial sync.
Hold a row lock from state load through save() or updateSyncStamp():
SQL uses SELECT ... FOR UPDATE in a transaction; Mongo uses a sync_lock
field with findAndModify(), including stale-lock recovery. Release locks
on collection switch, save, stamp-only updates, and __destruct().
Add updateSyncStamp() to State_Mongo for parity with SQL. Add unit tests
under StateTest/Sql and StateTest/Mongo (RowLockTest, InitialSyncTest).
@ralflang ralflang force-pushed the fix/ActiveSync_loops branch from 8901cd1 to c7e372f Compare June 8, 2026 07:25

@ralflang ralflang left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rebase before adding code to this. I will put it back into draft status now.

@ralflang ralflang marked this pull request as draft June 8, 2026 07:27
@ralflang ralflang mentioned this pull request Jun 8, 2026
…ruption

Add per-collection locking so concurrent PING/SYNC workers for the same
folder cannot interleave writes to horde_activesync_state:
- migration 24: horde_activesync_collection_lock (SQL) and HAS_collection_lock
  (Mongo)
- acquire/release collection locks around loadState(), save(), and
  updateServerIdInState()
- keep existing row locks (SELECT FOR UPDATE) held through save()
Tighten SQL/Mongo state writes:
- scope UPDATE/INSERT to sync_key, sync_folderid, sync_devid, and sync_user
- persist sync_pending as TEXT; only sync_data uses Horde_Db_Value_Binary
- wrap persist in transactions; retry concurrent INSERT as UPDATE
- preserve sync_pending on PING watermark saves (preservePending)
Detect and recover corrupt collection state:
- normalize invalid sync_data on load (reinitialize folder object)
- refuse to persist empty/array-shaped sync_data or non-folder objects
  (StaleState)
Tests: SQL/Mongo collection lock, SQL/Mongo row lock, SQL preservePending
saves, corrupt sync_data load/save guards.
Treat clearly invalid collection sync_data (arrays, pending-shaped blobs)
as StaleState on load so SYNC/PING reset the collection and return
STATUS_KEYMISMATCH instead of silently reinitializing at the same synckey.
Missing/unparseable blobs still rebuild folder state in place.
@TDannhauer TDannhauer marked this pull request as ready for review June 9, 2026 07:12
@TDannhauer

Copy link
Copy Markdown
Contributor Author

@ralflang please review

@ralflang

ralflang commented Jun 9, 2026

Copy link
Copy Markdown
Member

SYNCSTAMP_UPDATE_THRESHOLD and STATE_ROW_LOCK_STALE_SECONDS defined in Base.php but also Sql.php and Mongo.php - harmless but unnecessary and potential source of future trouble.

_acknowledgeExportedChange() only handles CHANGE/DRAFT, not ADD. For initial sync the messages come through as ADDs, but the code path that primes
_messages works through acknowledgeExportedMessage() invoked from updateState-driven export — verify that ADD changes during initial sync hit -> I don't know if this is an issue protocol-wise.

Did you test

horde-db-migrate 24; horde-db-migrate 23; horde-db-migrate 24

To make sure it's a viable roundtrip without data loss?

Overall I think it's OK to merge.

@TDannhauer

Copy link
Copy Markdown
Contributor Author

horde migrate 24 id used to create my table. downgrade to 23 I did not test, but it is trivial : up adds a table, and down drops it. no modification on existing tables.

@TDannhauer

Copy link
Copy Markdown
Contributor Author

thanks for the review, wil ladjust the code slightly to address it.

…c ACK

Move SYNCSTAMP_UPDATE_THRESHOLD and STATE_ROW_LOCK_STALE_SECONDS to
State_Base and remove duplicate definitions from State_Sql and State_Mongo.
Clarify in _acknowledgeExportedChange() that wire-format SYNC Add maps to
CHANGE_TYPE_CHANGE after Exporter_Sync::_getNextChange() normalizes bare
UIDs from the driver initial-sync short-circuit.
Add InitialSyncTest::testInitialSyncBareUidAcknowledgesExportedMessage()
to cover sync_pending entries stored as bare UIDs.
@TDannhauer TDannhauer merged commit e18208d into FRAMEWORK_6_0 Jun 9, 2026
0 of 8 checks passed
ralflang added a commit that referenced this pull request Jun 9, 2026
Release version 3.0.0-RC1

Merge pull request #41 from horde/fix/ActiveSync_loops
fix(activesync): consolidate state constants and document initial-sync ACK
fix(ActiveSync): force KEYMISMATCH when loading corrupt sync_data
Fix: Filename typo
fix(ActiveSync): harden state persist against races and sync_data corruption
docs(ActiveSync): clarify row lock behavior and sendNextChange contract
fix(activesync): serialize parallel state access with SQL and Mongo row locks
fix(activesync): repair initial sync, corrupt state, and nested MIME export
fix(activesync): reject corrupt collection sync_data on load and save
fix(activesync): separate PING watermark from SYNC modseq to stop iOS mail loops
refactor(ActiveSync): use HordeString instead of Horde_String in METHOD handling
Update MeetingRequest.php
Simplify method retrieval in fromvEvent
Refactor METHOD handling in EasMessageBuilder
fix(Iterator): use void return type for next() instead of ReturnTypeWillChange
fix(Device): restore isset guard for OS property access
Fix php and phpunit deprecations
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants