Skip to content

Fix: make iOS bundle extraction atomic and self-healing#153

Open
voicecode-bv wants to merge 1 commit into
NativePHP:mainfrom
voicecode-bv:fix/atomic-ios-bundle-extraction
Open

Fix: make iOS bundle extraction atomic and self-healing#153
voicecode-bv wants to merge 1 commit into
NativePHP:mainfrom
voicecode-bv:fix/atomic-ios-bundle-extraction

Conversation

@voicecode-bv

Copy link
Copy Markdown
Contributor

Problem

On first launch (and on bundle updates) the iOS app unzips app.zip into Documents/app via AppUpdateManager.extractZipParallel(). Under certain device timing / memory conditions this can leave a permanently broken install that crashes on a missing require on every launch, e.g.:

Warning: require(.../app/vendor/composer/../symfony/deprecation-contracts/function.php):
Failed to open stream: No such file or directory in .../vendor/composer/autoload_real.php
Fatal error: Uncaught Error: Failed opening required '.../symfony/deprecation-contracts/function.php'

The file is present in the bundle but missing on the device. Seen on App Store / TestFlight installs, intermittently.

Root cause

  1. Directory creation race. Phase 3 writes files on a .concurrent DispatchQueue, and every writer also calls createDirectory(withIntermediateDirectories: true) for its own parent. Many files share parent directories (vendor/symfony/...), so concurrent writers race to create the same intermediate directories — a TOCTOU race on APFS that can spuriously fail and abort the whole extraction. The error is caught in copyBundledApp(), but the half-written app (often already including .env) is left on disk and installed.version is never written.

  2. Readiness keyed off .env. hasApp() returns true as soon as .env exists. After a partial extraction .env may be present while other files are missing, so the next launch treats the broken install as complete and never re-extracts → crashes forever until reinstall.

Fix

  • Phase 2: pre-create all unique parent directories sequentially (deduped via a Set), so Phase 3 writers only touch leaf files — the directory race is gone.
  • Phase 4: after writing, verify every expected file exists on disk; a missing file throws instead of silently leaving a partial install.
  • copyBundledApp(): on any extraction failure, remove the partial app directory so the next launch re-extracts cleanly (self-healing).
  • hasApp(): key readiness off installed.version (written only after a fully successful extraction) instead of .env.

Net effect: extraction is now all-or-nothing. A failed/interrupted extract self-heals on the next launch instead of poisoning the install — including installs already broken by this bug in the field, which recover on the next app update.

Possible follow-up (not in this PR)

Phase 1 still buffers every file's bytes in memory simultaneously (fileDataMap), a jetsam risk on large vendor dirs / low-memory devices. Left out to keep the diff focused; with this change such a failure now self-heals rather than persisting, but bounding peak memory (stream/chunk writes) would be a worthwhile separate improvement.

Testing

Verified by inspection; the changed paths are guarded by existing print/throw diagnostics. There is no automated coverage for the native extraction path in the repo.

On first launch (and bundle updates) the app unzips app.zip into
Documents/app via extractZipParallel(). Two issues there can leave a
permanently broken install that crashes on a missing `require` (e.g.
"Failed to open stream: .../symfony/deprecation-contracts/function.php"):

1. Phase 3 writes files on a concurrent queue AND each writer calls
   createDirectory(withIntermediateDirectories:) for its own parent.
   Many files share parent dirs (vendor/symfony/...), so writers race to
   create the same intermediate directories - a TOCTOU race on APFS that
   can spuriously fail and abort extraction. The error is caught in
   copyBundledApp() but the half-written app (often including .env) is
   left on disk, and installed.version is never written.

2. hasApp() keys readiness off .env existing. After a partial extraction
   .env may be present while other files are missing, so the next launch
   treats the broken install as complete and never re-extracts -> crashes
   forever until reinstall. Reproduces intermittently depending on device
   timing/memory pressure.

Fixes:
- Pre-create all unique parent directories sequentially (Phase 2); Phase 3
  writers now only write leaf files, removing the directory race.
- Verify every expected file exists after writing (Phase 4); a missing
  file throws instead of silently producing a partial install.
- copyBundledApp() removes the partial app dir on failure, so the next
  launch re-extracts cleanly (self-healing).
- hasApp() now keys off installed.version (written only after a fully
  successful extraction) instead of .env.
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