Skip to content

feat(ios): replace PHP iterator with rsync for app copy step#30

Open
benjam-es wants to merge 2 commits intoNativePHP:mainfrom
benjam-es:feature/ios-build-rsync-copy
Open

feat(ios): replace PHP iterator with rsync for app copy step#30
benjam-es wants to merge 2 commits intoNativePHP:mainfrom
benjam-es:feature/ios-build-rsync-copy

Conversation

@benjam-es
Copy link

Problem

BuildIosAppCommand::copyLaravelAppIntoIosApp() uses a RecursiveIteratorIterator with FOLLOW_SYMLINKS to copy the Laravel app into the iOS build directory. This approach has three issues:

  1. Memory — Every file is collected into a PHP array before copying. On a standard Inertia project this consumes ~60 MB of memory. With local Composer path repositories (symlinked packages), the iterator follows symlinks into nested vendor/ directories, easily exceeding the default 128 MB memory limit and causing a silent exit code 255 with no error output.

  2. Wasted I/O — The iterator copies files that removeUnnecessaryFiles() immediately deletes afterwards (notably node_modules). On a real project this means ~19,000 files and ~240 MB of unnecessary disk writes every build.

  3. Speed — The two-pass approach (collect all files into memory, then iterate and copy) is slower than a native file copy tool.

Solution

Replace the PHP iterator with a single rsync -a --copy-links call using exclude patterns. A new getExcludedPaths() method provides a single source of truth for paths that should never enter the iOS bundle. Vendor package .gitattributes export-ignore patterns are also respected when present (relevant for locally symlinked packages during development).

removeUnnecessaryFiles() is unchanged — it continues to clean up after composer install which may recreate excluded directories.

Benchmarks

Profiled on kitchen-sink-mobile-vue (Vue/Inertia, 14 NativePHP plugins, stock mobile ^3.0, no symlinks):

Before (iterator) After (rsync)
Files iterated by PHP 36,116 0
Files copied 30,865 10,484
Files immediately deleted by cleanup 19,032 13
Wasted I/O 240 MB 0
Copy time 7,702 ms 2,500 ms
Cleanup time 1,418 ms 17 ms
Total time (copy + composer + cleanup) 11,250 ms 3,291 ms
Peak PHP memory 60.5 MB 2 MB
Final bundle files 11,833 10,471
Final bundle size 102 MB 93 MB

With locally symlinked packages (3 Composer path repos), the original iterator follows into each package's own node_modules/ and vendor/ directories:

Before After
Files iterated 59,741 0
Peak PHP memory 104+ MB (OOM at 128 MB) 2 MB
Build result Silent crash (exit 255) Completes normally

What changed

  • copyLaravelAppIntoIosApp() — replaced RecursiveIteratorIterator + manual copy loop with rsync -a --copy-links and exclude flags
  • getExcludedPaths() — new method, single source of truth for excluded paths (used by rsync)
  • loadVendorExportIgnorePatterns() — new method, reads .gitattributes export-ignore from vendor packages
  • removeUnnecessaryFiles() — added glob support for wildcard patterns in $directoriesToRemove, added vendor/*/*/vendor cleanup as safety net
  • Progress feedback — copy step wrapped in $this->components->task()

Test plan

  • Profiled on kitchen-sink-mobile-vue (stock mobile ^3.0, no symlinks)
  • Profiled on fresh React Inertia app with all plugins
  • Tested with locally symlinked Composer path repositories (3 packages)
  • Verified final bundle contents match expected output
  • Confirmed removeUnnecessaryFiles() still cleans up after composer install

Replace RecursiveIteratorIterator with rsync -a --copy-links in
copyLaravelAppIntoIosApp() to eliminate unnecessary file copying and
prevent OOM crashes with symlinked local packages.

The old approach collected all files into a PHP array before copying,
consuming 60+ MB on standard projects and exceeding the 128 MB memory
limit when Composer path repositories introduced nested vendor dirs.
It also copied ~19,000 files (notably node_modules) that
removeUnnecessaryFiles() immediately deleted.

- Add getExcludedPaths() as single source of truth for rsync excludes
- Add loadVendorExportIgnorePatterns() to respect .gitattributes
- Add glob support in removeUnnecessaryFiles for wildcard dir patterns
- Exclude non-runtime files (*.md, LICENSE*, docs, CI configs)
- Fix vendor nested dir pattern (vendor/*/*/vendor vs vendor/*/vendor)
- Wrap copy step in components->task() for progress feedback
@benjam-es
Copy link
Author

I spent so long working on profiling scripts to prove out the performance improvements I totally forgot to add any tests. Will add them now

Move hardcoded exclusion lists, copy logic, and cleanup logic out of
BuildIosAppCommand into BundleExclusions (data) and BundleFileManager
(behaviour). This makes the lists testable, maintainable, and reusable
across platforms.
@benjam-es
Copy link
Author

Benchmarks (kitchen-sink-vue)

Profiled against nativephp/kitchen-sink-vue — the copy + cleanup steps only (no Xcode build):

Before (v3.0.4) After (rsync)
Peak PHP memory 82.5 MB 24.0 MB
Time 6.0s 2.5s
Files 11,829 10,461
Size 70.7 MB 65.1 MB

Key benefits

  • rsync over PHP iterator — the old approach loaded 36k+ SplFileInfo objects and a visited-paths hash map into PHP memory, causing 58MB+ spikes that could trigger OOM on constrained systems. rsync delegates entirely to an external process with zero PHP memory growth
  • Proactive exclusion instead of copy-then-delete — rsync filters files during copy so unwanted files never touch disk, rather than copying everything and removing afterward. 1,368 fewer files in the final bundle
  • Local package support — respects .gitattributes export-ignore patterns from vendor packages, so symlinked dev packages don't leak test fixtures, CI configs, or docs into the bundle
  • Faster builds — 2.4x faster copy step. composer.lock and artisan are preserved during copy so composer install verifies the lock file instead of resolving from scratch
  • Extracted exclusion listsBundleExclusions is a pure data class with categorised constants (PROJECT, COPY_ONLY, CLEANUP_ONLY, VENDOR_PATTERNS, etc.) making it easy for contributors to add entries and reusable for Android

Tests

41 tests across two suites:

  • tests/Unit/BundleFileManagerTest.php — 10 tests covering exclusion building, cleanup logic, config merging
  • tests/Feature/IosBuildCopyTest.php — 31 tests covering rsync integration, removal behaviour, vendor export-ignore patterns, config path handling, and coverage assertions ensuring cleanup lists stay in sync with rsync excludes

@benjam-es
Copy link
Author

@simonhamp this restructure also addresses #2 I believe as we aim to centralise files and folder lists in a way they are reusable between copy exclusions as well as removals whilst bringing in user config)

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants