Skip to content

[SYNC] Android never requests .mindwtr-sync-fence-v1.json β€” fence URL resolves to data.json, sync fails "mutation fence record is malformed"Β #1132

Description

@ekoori

πŸ’» Platform

  • Desktop (Linux)
  • Desktop (macOS)
  • Desktop (Windows)
  • Mobile (Android)
  • Mobile (iOS)
  • Cloud / WebDAV server

ℹ️ Environment

  • App Version: Android: current Play Store build as of 2026-09-01 13:18 UTC. v1.2.6 was published 12:56 UTC the same day, so this is almost certainly 1.2.5. (The .mindwtr-sync-fence-v1.json behaviour implies >= 1.2.5.)
  • Install Channel(s): Android: Play Store. Desktop: same account, syncing successfully against the identical endpoint at the same time.
  • Sync Backend: WebDAV (self-hosted wsgidav 4.3.5 behind Caddy 2.11.4, plaintext, encryption off)
  • OS Version(s): Android (version not recorded); server Ubuntu 24.04
  • Device clocks auto-sync enabled: yes β€” phone measured ~1.4 s behind the server, which I have corrected for when aligning the two logs below.

Host redacted as https://example.com/dav throughout.

🧩 What happened?

Android sync fails on every attempt at step read-remote:

Remote sync mutation fence record is malformed

Desktop syncs the same data.json at the same endpoint continuously and without error.

The Android client never requests the fence file at all. I have server-side access logs for the whole session. Counting every request to .mindwtr-sync-fence-v1.json:

Client (User-Agent) Requests to .mindwtr-sync-fence-v1.json
tauri-plugin-http/2.5 (desktop) 27 — GET→404, PUT→201, GET→200 xN, DELETE→204. Correct lease cycle.
okhttp/4.12.0 (Android) 0

Instead, at the exact moment of the fence read, Android issues GET /dav/data.json. Aligning the two logs (phone ~1.4 s behind):

Android app log Server access log
13:18:32.576 Sync step {"step":"read-remote"} β€”
β€” 13:18:33.940 GET /dav/data.json β†’ 200 (3360 bytes)
13:18:32.673 Sync failed: Remote sync mutation fence record is malformed β€”

So the fence URL appears to resolve back to the document URL on Android. parseRecord then receives data.json β€” syntactically valid JSON, but with no schema / leaseId / ownerId / purpose / expiresAt β€” and correctly throws at the field check in packages/core/src/sync-remote-fence.ts.

This looks like the same class of defect already noted in packages/core/src/webdav.ts:

A short-lived Android URL fallback regression could serialize the unique probe URL back to data.json.

i.e. the same URL-resolution fallback, now affecting the fence path rather than the probe path. Note the fence filename begins with a dot, which is a plausible trigger for a URL-resolution fallback.

This is probably not fixed by v1.2.6. f24d80ad treats an oversized fence response as absence; here the fence is never fetched, so size never comes into it. My server's 404 body is 408 bytes, well under the 4096 limit from #1126.

πŸ”„ Reproduction Steps

  1. Point Desktop and Android at the same plaintext WebDAV folder, encryption off.
  2. Let Desktop sync so data.json exists and is valid.
  3. On Android, run Test Connection β€” it succeeds (the full capability probe passes; see below).
  4. Press Sync.
  5. Sync fails immediately with Remote sync mutation fence record is malformed; the UI shows settings.syncFailureGeneric ("Review Settings β†’ Sync and try again") and Last sync: Never.

Reproduces on every attempt β€” 5+ consecutive tries, plus repeats after app restart.

βœ… Expected result

Android acquires the fence at <folder>/.mindwtr-sync-fence-v1.json (creating it when absent, exactly as Desktop does) and completes the sync.

πŸ“‹ Diagnostic data

Android log (verbatim, two consecutive attempts):

{"ts":"2026-09-01T13:18:32.302Z","level":"info","scope":"sync","message":"Sync start","context":{"backend":"webdav"}}
{"ts":"2026-09-01T13:18:32.464Z","level":"info","scope":"sync","message":"Sync step","context":{"step":"read-local","sinceLastStepMs":"144"}}
{"ts":"2026-09-01T13:18:32.561Z","level":"info","scope":"sync","message":"Sync local reconcile","context":{"reconcile":"aligned-skip","durationMs":"2","tasks":"13"}}
{"ts":"2026-09-01T13:18:32.576Z","level":"info","scope":"sync","message":"Sync step","context":{"step":"read-remote","sinceLastStepMs":"113"}}
{"ts":"2026-09-01T13:18:32.673Z","level":"warn","scope":"sync","message":"Sync failed","context":{"error":"Remote sync mutation fence record is malformed"}}
{"ts":"2026-09-01T13:18:36.488Z","level":"info","scope":"sync","message":"Sync start","context":{"backend":"webdav"}}
{"ts":"2026-09-01T13:18:36.703Z","level":"info","scope":"sync","message":"Sync step","context":{"step":"read-remote","sinceLastStepMs":"121"}}
{"ts":"2026-09-01T13:18:36.784Z","level":"warn","scope":"sync","message":"Sync failed","context":{"error":"Remote sync mutation fence record is malformed"}}

Server access log β€” the Android capability probe, which passes in full. Every assertion in assertWebdavConditionalWriteSupport is satisfied:

GET    data.json                          -> 200  3360b
PUT    data.json.mindwtr-etag-probe-...   -> 201   (If-None-Match: *, create)
GET    data.json.mindwtr-etag-probe-...   -> 200
PUT    data.json.mindwtr-etag-probe-...   -> 412   (If-None-Match: * on existing, refused)
PUT    data.json.mindwtr-etag-probe-...   -> 204   (If-Match: current, replaced; ETag changes)
GET    data.json.mindwtr-etag-probe-...   -> 200
PUT    data.json.mindwtr-etag-probe-...   -> 412   (If-Match: stale, refused)
DELETE data.json.mindwtr-etag-probe-...   -> 412   (If-Match: stale, refused)
GET    data.json.mindwtr-etag-probe-...   -> 200   (ETag unchanged after refused delete)
DELETE data.json.mindwtr-etag-probe-...   -> 204   (If-Match: current)

No fence request appears anywhere in that sequence, or anywhere else in the session.

Server behaviour, for completeness:

  • Strong ETags are a SHA-256 of file content (64 hex, quoted), so they change iff content changes.
  • GET of a missing file β†’ 404 with a 408-byte text/html body, Content-Type: text/html; charset=utf-8.
  • OPTIONS β†’ DAV: 1,2.
  • Conditional semantics verified independently with curl: If-Match mismatch β†’ 412 with the write prevented, If-None-Match: * on an existing file β†’ 412, conditional GET β†’ 304.
  • No compression anywhere in the chain; Android's Accept-Encoding: identity is honoured.

Reproduces: every sync, 100%.

Possibly related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:syncSync engine and conflict resolutionbugSomething isn't workingplatform:androidAndroid-specificplatform:iosiOS-specificstatus:needs-confirmationFix landed; awaiting reporter or release confirmationtype:regressionPreviously worked, now broken

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions