Skip to content

Commit b579486

Browse files
authored
Possible fix for cocoapods cache access race (#993)
1 parent 9ebf9dd commit b579486

5 files changed

Lines changed: 443 additions & 25 deletions

File tree

server/src/main/java/com/defold/extender/process/ProcessExecutor.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@ public int execute(List<String> args) throws IOException, InterruptedException {
8383
System.out.println(debugBuffer.toString());
8484
}
8585

86-
if (exitValue > 0) {
86+
// note: a negative exit value means the process was terminated by a signal,
87+
// which is a failure just like a positive exit code
88+
if (exitValue != 0) {
8789
throw new IOException(output.toString());
8890
}
8991

@@ -109,8 +111,10 @@ public void putEnv(String key, String value) {
109111
env.put(key, value);
110112
}
111113

112-
public void putEnv(Map<String, String> inputEnv) throws NullPointerException {
113-
env.putAll(inputEnv);
114+
public void putEnv(Map<String, String> inputEnv) {
115+
for (Map.Entry<String, String> entry : inputEnv.entrySet()) {
116+
putEnv(entry.getKey(), entry.getValue());
117+
}
114118
}
115119

116120
public Map<String, String> getEnv() {

server/src/main/java/com/defold/extender/process/ProcessUtils.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ public static String execCommand(List<String> args, File cwd, Map<String, String
3636
}
3737

3838
try {
39-
if (pe.execute(args) != 0) {
40-
throw new ExtenderException(pe.getOutput());
41-
}
39+
// ProcessExecutor.execute() throws IOException on any non-zero exit code,
40+
// so a normal return here always means success
41+
pe.execute(args);
4242
} catch (IOException | InterruptedException e) {
4343
throw new ExtenderException(e, pe.getOutput());
4444
}

server/src/main/java/com/defold/extender/services/cocoapods/CocoaPodsService.java

Lines changed: 228 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import java.io.IOException;
3232
import java.nio.charset.Charset;
3333
import java.nio.file.Files;
34+
import java.nio.file.InvalidPathException;
3435
import java.nio.file.Path;
3536
import java.util.ArrayList;
3637
import java.util.List;
@@ -40,6 +41,7 @@
4041
import java.util.HashSet;
4142
import java.util.LinkedHashSet;
4243
import 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

Comments
 (0)