Skip to content

fix(app): trigger iCloud sync before concluding a backup is missing (SELF-3933) - #2277

Open
seshanthS wants to merge 2 commits into
self-3932-restore-failure-visibilityfrom
self-3933-icloud-sync-restore
Open

fix(app): trigger iCloud sync before concluding a backup is missing (SELF-3933)#2277
seshanthS wants to merge 2 commits into
self-3932-restore-failure-visibilityfrom
self-3933-icloud-sync-restore

Conversation

@seshanthS

Copy link
Copy Markdown
Collaborator

Closes SELF-3933.

Stack: #2272#2273 → this. Base is self-3932-restore-failure-visibility; retarget as the stack merges.

Problem

ios.ts download() gated on CloudStorage.exists() — natively a plain FileManager.fileExists(atPath:). iCloud syncs metadata eagerly but file contents lazily: on a fresh device the backup exists only as a .encrypted-private-key.icloud placeholder until something calls startDownloadingUbiquitousItem. Nothing ever did (triggerSync() was unused), so exists stayed false and the user was told "no backup found" — on a fresh install, exactly when restore matters (SELF-2486's repro).

Changes

  • hasRemoteBackup() discriminator — after the local-file check fails: probe exists(PLACEHOLDER_FILE_PATH) (throw-free), then fall back to readdir(FOLDER) matching any entry containing the filename (tolerates placeholder-naming variants). A successful listing is authoritative; a rejected listing is ambiguous — native contentsOfDirectory throws the same ERR_READ_ERROR for "folder never created" and a genuine read failure — so rejection gets remoteProbeAttempts re-probes before concluding "no backup", giving a fresh device's folder metadata a window to arrive instead of a false terminal "no backup found".
  • waitForBackupFile() sync wait — fires triggerSync at both the plain and placeholder paths (Promise.allSettled) on every poll tick, then polls exists against a deadline. Both paths because Apple doesn't document which URL isUbiquitousItem accepts for a non-materialized item and the wrong one throws instead of enqueueing; every tick because the enqueue is idempotent and a single dropped request must not strand the user in a dead retry loop. The poll is the arbiter regardless of what triggerSync reports.
  • New backup_not_synced reason + copy — "Your backup is still syncing from iCloud. Keep the app open and try again in a moment." Distinct from no_backup_found in both UI and the analytics reason (AC3). Flows through the choice screen automatically via the CloudBackupErrorReason union from fix(app): surface cloud restore failures to the user and telemetry (SELF-3932) #2273.
  • PLACEHOLDER_FILE_PATH in helpers.ts, adjacent to ENCRYPTED_FILE_PATH (now derived from FILE_NAME so they can't diverge).
  • IosDownloadOptions (syncTimeoutMs 30s / pollIntervalMs 1s / remoteProbeAttempts 3) forwarded by index.ts on the iOS branch only — production callers pass nothing; tests inject tiny budgets instead of fake timers.

AC status

  • AC1 (isCloudAvailable guard): shipped in fix(app): surface cloud restore failures to the user and telemetry (SELF-3932) #2273, unchanged here.
  • AC2: deliberate deviation — the AC says triggerSync/exists/readFile "all inside withRetries". withRetries retries on throw and destroys the original error at exhaustion; it can't express a boolean poll and would erase the error codes classification needs. Instead the bounded 30s deadline poll is the retry for exists (vs withRetries' ~9s — satisfying the AC's intent, "a budget suited to first sync"), triggerSync is repeated fire-and-forget with the poll as arbiter, and readFile keeps withRetries as before.
  • AC3: backup_not_synced vs no_backup_found in copy + reason.
  • AC4: verified moot — native FileUtils.sanitizePath strips ^/+, so the // vs / JS constants resolve to the same container path; a JS-layer path-consistency test pins the three constants to the same folder/filename.

Assumptions / notes for reviewers

  • Placeholder naming .name.icloud is Apple convention, not contract. The readdir includes(FILE_NAME) fallback tolerates variants; if both probes miss, behavior degrades to today's (no regression).
  • Whether isUbiquitousItem accepts the plain path for a placeholder is undocumented — the double-fire makes it moot in code, but only fresh-device QA proves materialization end-to-end.
  • Metadata lag beyond the ~3s probe budget still reads as "no backup found"; not closable without NSMetadataQuery, which this library doesn't expose. Code comment in hasRemoteBackup.
  • Worst-case UX ≈ 40s of "Recovering…" (30s poll + ~9s readFile retries) before backup_not_synced.
  • Future cheap add (not scope): a syncWaited property on CLOUD_RESTORE_SUCCESS to size this fix's impact.

Validation

pnpm --filter @selfxyz/mobile-app run test    # 1271 passed, 111 suites (10 net new)
pnpm --filter @selfxyz/mobile-app run types   # clean
pnpm --filter @selfxyz/mobile-app run lint    # 0 errors

All 31 pre-existing cloud-backup tests pass with zero body edits (mock factory + beforeEach defaults only). New coverage: placeholder → triggerSync at both paths → materializes → restores; never materializes → backup_not_synced; triggerSync rejected but file lands → still restores; placeholder found via folder listing → sync wait; unrelated listing → instant no_backup_found with triggerSync never called; persistent listing failure → probes exhaust → no_backup_found; already-local file skips the sync machinery entirely; exists rejecting mid-poll aborts to backup_read_failed; path-constant consistency.

Device QA required — unit tests cannot substitute (simulator iCloud is flaky; real devices only):

  1. Backup on device A → fresh install on device B (same Apple ID) → restore immediately. Must succeed, never "did you back it up previously?". This is the test that proves placeholder naming, triggerSync materialization and the 30s budget.
  2. Signed out of iCloud → "sign in to iCloud" (shipped in fix(app): surface cloud restore failures to the user and telemetry (SELF-3932) #2273, re-verify).
  3. Never backed up → "no backup found".
  4. Enable then disable backup → restore → "no backup found".

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
self-webview-app Ignored Ignored Preview Aug 24, 2026 2:19pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (3)
  • main
  • dev
  • staging

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 171f580a-b9ee-4092-a1a8-93fc852421c7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 51af213c50

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/src/services/cloud-backup/ios.ts Outdated
// A successful listing is authoritative: the folder is materialised, so
// the file is either visible (possibly under a placeholder name this
// check tolerates) or genuinely absent.
return entries.some(entry => entry.includes(FILE_NAME));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Retry empty iCloud listings before declaring the backup absent

On a fresh device, the local iCloud container can already exist while its remote file metadata is still arriving, so readdir(FOLDER) may successfully return an empty array. This immediate return bypasses all remaining remoteProbeAttempts, reports no_backup_found, and never calls triggerSync, reproducing the restore failure this change is intended to fix. Treat an empty successful listing as inconclusive and continue probing for the configured budget before concluding that no backup exists.

Useful? React with 👍 / 👎.

seshanthS and others added 2 commits August 24, 2026 19:49
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@seshanthS
seshanthS force-pushed the self-3933-icloud-sync-restore branch from 76d0e55 to 7312090 Compare August 24, 2026 14:19
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.

1 participant