3131import java .io .IOException ;
3232import java .nio .charset .Charset ;
3333import java .nio .file .Files ;
34+ import java .nio .file .InvalidPathException ;
3435import java .nio .file .Path ;
3536import java .util .ArrayList ;
3637import java .util .List ;
4041import java .util .HashSet ;
4142import java .util .LinkedHashSet ;
4243import java .util .UUID ;
44+ import java .util .concurrent .locks .ReentrantReadWriteLock ;
4345
4446@ Service
4547@ ConditionalOnProperty (prefix = "extender" , name = "cocoapods.enabled" , havingValue = "true" )
@@ -66,6 +68,14 @@ private static class InstalledPods {
6668 private static final String CURRENT_CACHE_DIR_FILE = "current_pod_cache.txt" ;
6769 private static final String OLD_CACHE_DIR_FILE = "old_pod_caches.txt" ;
6870 private final Object syncLock = new Object ();
71+ // Guards the contents of the CocoaPods home directory (CP_HOME_DIR).
72+ // Pod installations take the read lock and run concurrently with each other, while anything
73+ // that mutates the shared spec repo or download cache (repo update, cache rotation, cleanup)
74+ // takes the write lock. Without this a 'pod repo update' or a cache rotation can run while a
75+ // build is doing 'pod install', which may resolve against half-written CDN metadata or copy a
76+ // partially extracted pod out of the shared download cache. A fair lock is used so that a
77+ // pending repo update is not starved by a continuous stream of builds.
78+ private final ReentrantReadWriteLock cacheLock = new ReentrantReadWriteLock (true );
6979 private final TemplateExecutor templateExecutor = new TemplateExecutor ();
7080
7181 private final String podfileTemplateContents ;
@@ -95,9 +105,15 @@ public void runAfterStartup() {
95105 updateSpecRepo ();
96106 } else {
97107 LOGGER .info ("Cocoapods has no current cache dir or prefix is changed. Created..." );
108+ Path newCacheDir = generateCacheDirPath ();
109+ try {
110+ Files .createDirectories (newCacheDir );
111+ } catch (IOException |UnsupportedOperationException |SecurityException exc ) {
112+ LOGGER .warn ("Cannot create pod cache directory {}" , newCacheDir , exc );
113+ }
98114 synchronized (this .syncLock ) {
99- this .currentCacheDir = generateCacheDirPath () ;
100- storeCurrentCacheDir (this . currentCacheDir );
115+ this .currentCacheDir = newCacheDir ;
116+ storeCurrentCacheDir (newCacheDir );
101117 }
102118 initializeTrunkRepo ();
103119 }
@@ -166,15 +182,101 @@ private void unpackXCFrameworks(CocoaPodsServiceBuildState cocoapodsBuildState,
166182 handledPods .add (podName );
167183 File unpackScript = Path .of (cocoapodsBuildState .getTargetSupportFilesDir ().toString (), podName , String .format ("%s-xcframeworks.sh" , podName )).toFile ();
168184 if (unpackScript .exists ()) {
169- ProcessUtils .execCommand (List .of (
185+ String log = ProcessUtils .execCommand (List .of (
170186 unpackScript .getAbsolutePath ()
171187 ), null , spec .parsedXCConfig );
188+ LOGGER .info ("Unpacked xcframeworks for {}:\n {}" , podName , log );
189+ String failure = findUnpackFailure (log );
190+ if (failure != null ) {
191+ throw new ExtenderException (String .format (
192+ "Unable to unpack xcframework for pod '%s' (ARCHS=%s, PLATFORM_NAME=%s): %s" ,
193+ podName ,
194+ spec .parsedXCConfig .get ("ARCHS" ),
195+ spec .parsedXCConfig .get ("PLATFORM_NAME" ),
196+ failure ));
197+ }
198+ } else if (hasVendoredXCFramework (spec )) {
199+ // the pod ships an .xcframework but Cocoapods generated no script to unpack it,
200+ // so the framework search paths in the xcconfig will point at an empty directory
201+ LOGGER .warn ("Pod {} has vendored xcframeworks {} but no unpack script {}" , podName , spec .vendoredFrameworks , unpackScript );
172202 } else {
173203 LOGGER .debug ("No xcframework unpack script for {}" , podName );
174204 }
175205 }
176206 }
177207
208+ static boolean hasVendoredXCFramework (PodBuildSpec spec ) {
209+ return spec .vendoredFrameworks .stream ().anyMatch (f -> f .contains (".xcframework" ));
210+ }
211+
212+ /**
213+ * Scan the output of a Cocoapods generated '<pod>-xcframeworks.sh' script for a failure to
214+ * select a slice. The script prints a warning and exits with code 0 in that case, so the
215+ * process exit code alone does not tell us that nothing was unpacked.
216+ * @param scriptOutput Combined stdout/stderr of the unpack script
217+ * @return The offending line, or null if the output contains no such warning
218+ */
219+ static String findUnpackFailure (String scriptOutput ) {
220+ if (scriptOutput == null ) {
221+ return null ;
222+ }
223+ for (String line : scriptOutput .split ("\\ R" )) {
224+ // "warning: [CP] Unable to find matching .xcframework slice in '...' for the current build architectures (...)."
225+ if (line .contains ("[CP]" ) && line .contains ("Unable to find matching" )) {
226+ return line .trim ();
227+ }
228+ }
229+ return null ;
230+ }
231+
232+ /**
233+ * Verify that every include/framework search path pointing into the XCFrameworkIntermediates
234+ * directory was actually populated by unpackXCFrameworks(). Cocoapods writes those paths into
235+ * the generated xcconfig whether or not the .xcframework was ever unpacked, so without this
236+ * check a skipped or failed unpack only surfaces later as a confusing compiler error such as
237+ * "'SomeHeader.h' file not found".
238+ * @param unpackedFrameworksDir The XCFrameworkIntermediates directory for this build
239+ * @param pods The resolved pod build specs
240+ */
241+ static void validateUnpackedFrameworks (File unpackedFrameworksDir , List <PodBuildSpec > pods ) throws ExtenderException {
242+ Path unpackedRoot = unpackedFrameworksDir .toPath ().toAbsolutePath ().normalize ();
243+ List <String > errors = new ArrayList <>();
244+ for (PodBuildSpec spec : pods ) {
245+ Set <File > searchPaths = new LinkedHashSet <>(spec .includePaths );
246+ if (spec .frameworkSearchPaths != null ) {
247+ searchPaths .addAll (spec .frameworkSearchPaths );
248+ }
249+ for (File searchPath : searchPaths ) {
250+ Path path = Path .of (unescapeWhitespace (searchPath .toString ())).toAbsolutePath ().normalize ();
251+ if (!path .startsWith (unpackedRoot ) || path .equals (unpackedRoot )) {
252+ continue ;
253+ }
254+ String [] entries = path .toFile ().list ();
255+ if (entries == null || entries .length == 0 ) {
256+ errors .add (String .format ("pod '%s' expects unpacked xcframework content in '%s' but that directory is %s" ,
257+ spec .name , path , entries == null ? "missing" : "empty" ));
258+ }
259+ }
260+ }
261+ if (!errors .isEmpty ()) {
262+ throw new ExtenderException ("Cocoapods xcframework unpacking did not produce the expected output:\n "
263+ + String .join ("\n " , errors ));
264+ }
265+ }
266+
267+ // search paths parsed from an xcconfig keep whitespace escaped (see CommandLineTokenizer.escapeWhitespace)
268+ static String unescapeWhitespace (String path ) {
269+ StringBuilder result = new StringBuilder ();
270+ for (int i = 0 ; i < path .length (); ++i ) {
271+ char c = path .charAt (i );
272+ if (c == '\\' && i + 1 < path .length () && Character .isWhitespace (path .charAt (i + 1 ))) {
273+ continue ;
274+ }
275+ result .append (c );
276+ }
277+ return result .toString ();
278+ }
279+
178280 void generateSwiftCompatabilityModule (List <PodBuildSpec > pods ) {
179281 for (PodBuildSpec spec : pods ) {
180282 if (spec .swiftSourceFiles .isEmpty ()) {
@@ -211,6 +313,17 @@ private Set<String> getPodDeps(Map<String, List<String>> specDepsMap, List<Strin
211313 * @return An InstalledPods object with installed pods
212314 */
213315 private InstalledPods installPods (CocoaPodsServiceBuildState cocoapodsBuildState ) throws IOException , ExtenderException {
316+ // hold the shared lock for the whole installation so that the cache dir cannot be rotated,
317+ // updated or deleted underneath us between the 'pod install' and the 'pod spec cat' calls
318+ cacheLock .readLock ().lock ();
319+ try {
320+ return installPodsLocked (cocoapodsBuildState );
321+ } finally {
322+ cacheLock .readLock ().unlock ();
323+ }
324+ }
325+
326+ private InstalledPods installPodsLocked (CocoaPodsServiceBuildState cocoapodsBuildState ) throws IOException , ExtenderException {
214327 LOGGER .info ("Installing pods" );
215328 Path cacheDir ;
216329 // store current cache dir into local variable to use the same value for all 'pod' runs
@@ -311,6 +424,19 @@ public boolean accept(File f) {
311424 installedPods .pods .add (entry .getKey ());
312425 }
313426
427+ // Podfile.lock lists what was resolved, the Pods directory holds what actually landed on
428+ // disk. The loop below only walks disk -> lock, so check the other direction here as well:
429+ // a pod that resolved but was not materialised is otherwise completely invisible.
430+ Set <String > podDirNames = new HashSet <>();
431+ for (File podDir : podsNames ) {
432+ podDirNames .add (podDir .getName ());
433+ }
434+ for (String podName : podVersions .keySet ()) {
435+ if (!podDirNames .contains (podName )) {
436+ LOGGER .warn ("Pod {} is listed in Podfile.lock but has no directory in {}" , podName , podsDir );
437+ }
438+ }
439+
314440 for (File podDir : podsNames ) {
315441 String podName = podDir .getName ();
316442 if (podVersions .containsKey (podName )) {
@@ -438,6 +564,7 @@ public ResolvedPods resolveDependencies(PlatformConfig config, ExtenderBuildStat
438564 }
439565 }
440566 unpackXCFrameworks (cocoapodsBuildState , pods );
567+ validateUnpackedFrameworks (cocoapodsBuildState .getUnpackedFrameworksDir (), pods );
441568 generateSwiftCompatabilityModule (pods );
442569
443570 dumpDir (jobDir , 0 );
@@ -497,6 +624,7 @@ private void storeCurrentCacheDir(Path currentCacheDir) {
497624 }
498625
499626 private void initializeTrunkRepo () {
627+ cacheLock .writeLock ().lock ();
500628 try {
501629 Path cacheDir ;
502630 synchronized (syncLock ) {
@@ -514,11 +642,22 @@ private void initializeTrunkRepo() {
514642 LOGGER .debug ("\n " + log );
515643 } catch (ExtenderException exc ) {
516644 LOGGER .warn ("Exception during repo init" , exc );
517- }
645+ } finally {
646+ cacheLock .writeLock ().unlock ();
647+ }
518648 }
519649
520650 @ Scheduled (cron ="${extender.cocoapods.cache-dir-rotate-cron}" )
521651 public void rotatePodCacheDirectory () {
652+ cacheLock .writeLock ().lock ();
653+ try {
654+ rotatePodCacheDirectoryLocked ();
655+ } finally {
656+ cacheLock .writeLock ().unlock ();
657+ }
658+ }
659+
660+ private void rotatePodCacheDirectoryLocked () {
522661 LOGGER .info ("Rotate pod cache directory" );
523662 Path newCacheDir = generateCacheDirPath ();
524663 Path cacheDir ;
@@ -541,38 +680,106 @@ public void rotatePodCacheDirectory() {
541680 }
542681 synchronized (this .syncLock ) {
543682 this .currentCacheDir = newCacheDir ;
544- storeCurrentCacheDir (currentCacheDir );
683+ storeCurrentCacheDir (newCacheDir );
545684 }
546685 initializeTrunkRepo ();
547686 }
548687
549688 @ Scheduled (cron ="${extender.cocoapods.old-cache-clean-cron}" )
550689 private void cleanupOldCacheDirectories () {
690+ cacheLock .writeLock ().lock ();
691+ try {
692+ cleanupOldCacheDirectoriesLocked ();
693+ } finally {
694+ cacheLock .writeLock ().unlock ();
695+ }
696+ }
697+
698+ private void cleanupOldCacheDirectoriesLocked () {
551699 LOGGER .info ("Cleanup old cache directories" );
700+ Path cacheDir ;
701+ synchronized (this .syncLock ) {
702+ cacheDir = this .currentCacheDir ;
703+ }
704+ Path activeCacheDir = cacheDir .toAbsolutePath ().normalize ();
552705 File oldDirFile = Path .of (this .homeDirPrefix , CocoaPodsService .OLD_CACHE_DIR_FILE ).toFile ();
553- if (oldDirFile .exists ()) {
554- try (BufferedReader reader = new BufferedReader (new FileReader (oldDirFile ))) {
555- String strPath = reader .readLine ();
556- while (strPath != null ) {
557- LOGGER .info ("Remove old pod cache directory: {}" , strPath );
558- Path dirPath = Path .of (strPath );
559- File path = dirPath .toFile ();
560- if (path .exists ()) {
561- FileUtils .deleteDirectory (path );
562- }
563- strPath = reader .readLine ();
706+ if (!oldDirFile .exists ()) {
707+ LOGGER .warn ("File with old cache paths doesn't exist. Cleanup skipped" );
708+ return ;
709+ }
710+
711+ List <String > retained = new ArrayList <>();
712+ try (BufferedReader reader = new BufferedReader (new FileReader (oldDirFile ))) {
713+ String strPath = reader .readLine ();
714+ while (strPath != null ) {
715+ String path = strPath .trim ();
716+ if (!path .isEmpty () && !removeOldCacheDirectory (path , activeCacheDir )) {
717+ retained .add (path );
564718 }
565- } catch (IOException io ) {
566- LOGGER .warn ("Exception while read old cache paths file" , io );
719+ strPath = reader .readLine ();
567720 }
721+ } catch (IOException io ) {
722+ // leave the file untouched so that nothing is forgotten
723+ LOGGER .warn ("Exception while read old cache paths file" , io );
724+ return ;
725+ }
726+
727+ if (retained .isEmpty ()) {
568728 oldDirFile .delete ();
569729 } else {
570- LOGGER .warn ("File with old cache paths doesn't exist. Cleanup skipped" );
730+ // keep the directories we failed to remove so the next run retries them, instead of
731+ // dropping the whole file and leaking them for good
732+ LOGGER .warn ("{} old pod cache directories could not be removed and will be retried" , retained .size ());
733+ try (FileWriter writer = new FileWriter (oldDirFile , false )) {
734+ for (String path : retained ) {
735+ writer .append (path );
736+ writer .append ("\n " );
737+ }
738+ } catch (IOException exc ) {
739+ LOGGER .warn ("Error while rewriting old cache paths file" , exc );
740+ }
741+ }
742+ }
743+
744+ /**
745+ * Remove a single old pod cache directory.
746+ * @param strPath Path of the directory to remove
747+ * @param activeCacheDir The cache directory currently in use, which must never be removed
748+ * @return true if the directory is gone or should not be retried, false if removal failed
749+ */
750+ private boolean removeOldCacheDirectory (String strPath , Path activeCacheDir ) {
751+ Path dirPath ;
752+ try {
753+ dirPath = Path .of (strPath );
754+ } catch (InvalidPathException exc ) {
755+ LOGGER .warn ("Ignoring malformed old pod cache path {}" , strPath , exc );
756+ return true ;
757+ }
758+ // never delete the directory builds are currently using. It gets appended to the file
759+ // again by the next rotation, so it is safe to drop it from the list here.
760+ if (dirPath .toAbsolutePath ().normalize ().equals (activeCacheDir )) {
761+ LOGGER .warn ("Skip removal of pod cache directory {}, it is the current one" , strPath );
762+ return true ;
763+ }
764+ File path = dirPath .toFile ();
765+ if (!path .exists ()) {
766+ return true ;
767+ }
768+ LOGGER .info ("Remove old pod cache directory: {}" , strPath );
769+ try {
770+ FileUtils .deleteDirectory (path );
771+ return true ;
772+ } catch (IOException exc ) {
773+ // a single failure must not abort the cleanup of the remaining directories
774+ LOGGER .warn ("Unable to remove old pod cache directory {}" , strPath , exc );
775+ return false ;
571776 }
572777 }
573778
574779 @ Scheduled (initialDelay =3600000 , fixedDelayString ="${extender.cocoapods.repo-update-interval:3600000}" )
575780 public void updateSpecRepo () {
781+ // exclusive: 'pod repo update' rewrites the spec repo that concurrent installs read from
782+ cacheLock .writeLock ().lock ();
576783 try {
577784 LOGGER .info ("Run pod spec update" );
578785 Path cacheDir ;
@@ -590,6 +797,8 @@ public void updateSpecRepo() {
590797 LOGGER .debug ("\n " + log );
591798 } catch (ExtenderException exc ) {
592799 LOGGER .warn ("Exception during spec repo update" , exc );
800+ } finally {
801+ cacheLock .writeLock ().unlock ();
593802 }
594803 }
595804}
0 commit comments