Skip to content

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

Closed
voicecode-bv wants to merge 1 commit into
mainfrom
fix/atomic-ios-app-extraction
Closed

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

Conversation

@voicecode-bv

Copy link
Copy Markdown
Owner

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. Reported 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.

Notes / possible follow-up

Phase 1 still buffers every file's bytes in memory simultaneously (fileDataMap), which is a jetsam risk on large vendor dirs / low-memory devices. Left as-is here 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

Logic verified by inspection; the changed paths are guarded by existing print/throw diagnostics. No automated coverage exists for the native extraction path in this 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.
@voicecode-bv

Copy link
Copy Markdown
Owner Author

Superseded by the upstream PR NativePHP#153, which is based on the current upstream main (this branch was based on a stale fork main).

@voicecode-bv voicecode-bv deleted the fix/atomic-ios-app-extraction branch June 3, 2026 13:29
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