diff --git a/README.md b/README.md index 72f9e8f9..813ee89f 100755 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ _XCRemoteCache is a remote cache tool for Xcode projects. It reuses target artif - [Limitations](#limitations) - [FAQ](#faq) - [Development](#development) +- [Architectural Designs](#architectural-designs) - [Release](#release) * [Releasing CocoaPods plugin](#releasing-cocoapods-plugin) * [Building release package](#building-release-package) @@ -291,7 +292,7 @@ where ```shell ditto "${SCRIPT_INPUT_FILE_0}" "${SCRIPT_OUTPUT_FILE_0}" -[ -f "${SCRIPT_INPUT_FILE_1}" ] && ditto "${SCRIPT_INPUT_FILE_1}" "${SCRIPT_OUTPUT_FILE_1}" || rm "${SCRIPT_OUTPUT_FILE_1}" +[ -f "${SCRIPT_INPUT_FILE_1}" ] && ditto "${SCRIPT_INPUT_FILE_1}" "${SCRIPT_OUTPUT_FILE_1}" || rm -f "${SCRIPT_OUTPUT_FILE_1}" ``` where @@ -469,6 +470,10 @@ Follow the [FAQ](docs/FAQ.md) page. Follow the [Development](docs/Development.md) guide. It has all the information on how to get started. +## Architectural designs + +Follow the [Architectural designs](docs/design/ArchitecturalDesigns.md) document that describes and documents XCRemoteCache designs and implementation details. + ## Release To release a version, in [Releases](https://github.com/spotify/XCRemoteCache/releases) draft a new release with `v0.3.0{-rc0}` tag format. diff --git a/Sources/XCRemoteCache/Commands/Postbuild/PostbuildContext.swift b/Sources/XCRemoteCache/Commands/Postbuild/PostbuildContext.swift index d71ea9ae..addb9c9c 100644 --- a/Sources/XCRemoteCache/Commands/Postbuild/PostbuildContext.swift +++ b/Sources/XCRemoteCache/Commands/Postbuild/PostbuildContext.swift @@ -89,6 +89,9 @@ public struct PostbuildContext { var publicHeadersFolderPath: URL? /// XCRemoteCache is explicitly disabled let disabled: Bool + /// The LLBUILD_BUILD_ID ENV that describes the compilation identifier + /// it is used in the swift-frontend flow + let llbuildIdLockFile: URL } extension PostbuildContext { @@ -149,5 +152,10 @@ extension PostbuildContext { publicHeadersFolderPath = builtProductsDir.appendingPathComponent(publicHeadersPath) } disabled = try env.readEnv(key: "XCRC_DISABLED") ?? false + let llbuildId: String = try env.readEnv(key: "LLBUILD_BUILD_ID") + llbuildIdLockFile = SwiftFrontendContext.buildLlbuildIdSharedLockUrl( + llbuildId: llbuildId, + tmpDir: targetTempDir + ) } } diff --git a/Sources/XCRemoteCache/Commands/Postbuild/XCPostbuild.swift b/Sources/XCRemoteCache/Commands/Postbuild/XCPostbuild.swift index cc597bf5..235f56c1 100644 --- a/Sources/XCRemoteCache/Commands/Postbuild/XCPostbuild.swift +++ b/Sources/XCRemoteCache/Commands/Postbuild/XCPostbuild.swift @@ -60,6 +60,7 @@ public class XCPostbuild { dependenciesWriter: FileDependenciesWriter.init, dependenciesReader: FileDependenciesReader.init, markerWriter: NoopMarkerWriter.init, + llbuildLockFile: context.llbuildIdLockFile, fileManager: fileManager ) diff --git a/Sources/XCRemoteCache/Commands/Prebuild/PrebuildContext.swift b/Sources/XCRemoteCache/Commands/Prebuild/PrebuildContext.swift index d36bcfaf..a7e43e55 100644 --- a/Sources/XCRemoteCache/Commands/Prebuild/PrebuildContext.swift +++ b/Sources/XCRemoteCache/Commands/Prebuild/PrebuildContext.swift @@ -48,6 +48,9 @@ public struct PrebuildContext { let overlayHeadersPath: URL /// XCRemoteCache is explicitly disabled let disabled: Bool + /// The LLBUILD_BUILD_ID ENV that describes the compilation identifier + /// it is used in the swift-frontend flow + let llbuildIdLockFile: URL } extension PrebuildContext { @@ -72,5 +75,10 @@ extension PrebuildContext { /// Note: The file has yaml extension, even it is in the json format overlayHeadersPath = targetTempDir.appendingPathComponent("all-product-headers.yaml") disabled = try env.readEnv(key: "XCRC_DISABLED") ?? false + let llbuildId: String = try env.readEnv(key: "LLBUILD_BUILD_ID") + llbuildIdLockFile = SwiftFrontendContext.buildLlbuildIdSharedLockUrl( + llbuildId: llbuildId, + tmpDir: targetTempDir + ) } } diff --git a/Sources/XCRemoteCache/Commands/Prebuild/XCPrebuild.swift b/Sources/XCRemoteCache/Commands/Prebuild/XCPrebuild.swift index 97fc759a..b4c8dd8c 100644 --- a/Sources/XCRemoteCache/Commands/Prebuild/XCPrebuild.swift +++ b/Sources/XCRemoteCache/Commands/Prebuild/XCPrebuild.swift @@ -55,6 +55,7 @@ public class XCPrebuild { dependenciesWriter: FileDependenciesWriter.init, dependenciesReader: FileDependenciesReader.init, markerWriter: lazyMarkerWriterFactory, + llbuildLockFile: context.llbuildIdLockFile, fileManager: fileManager ) diff --git a/Sources/XCRemoteCache/Commands/SwiftFrontend/SwiftFrontendContext.swift b/Sources/XCRemoteCache/Commands/SwiftFrontend/SwiftFrontendContext.swift new file mode 100644 index 00000000..c2d0d070 --- /dev/null +++ b/Sources/XCRemoteCache/Commands/SwiftFrontend/SwiftFrontendContext.swift @@ -0,0 +1,42 @@ +// Copyright (c) 2023 Spotify AB. +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import Foundation + +struct SwiftFrontendContext { + /// File lock used for synchronizing multiple invocations + let invocationLockFile: URL +} + +extension SwiftFrontendContext { + init(_ swiftcContext: SwiftcContext, env: [String: String]) throws { + /// The LLBUILD_BUILD_ID ENV that describes the entire (incl. parent's swiftc) bui;d + let llbuildId: String = try env.readEnv(key: "LLBUILD_BUILD_ID") + invocationLockFile = Self.self.buildLlbuildIdSharedLockUrl( + llbuildId: llbuildId, + tmpDir: swiftcContext.tempDir + ) + } + + /// Generate the filename to be used to synchronize multiple swift-frontend invocations + /// The same file is used in prebuild, xcswift-frontend and postbuild (to clean it up) + static func buildLlbuildIdSharedLockUrl(llbuildId: String, tmpDir: URL) -> URL { + return tmpDir.appendingPathComponent(llbuildId).appendingPathExtension("lock") + } +} diff --git a/Sources/XCRemoteCache/Commands/SwiftFrontend/SwiftFrontendOrchestrator.swift b/Sources/XCRemoteCache/Commands/SwiftFrontend/SwiftFrontendOrchestrator.swift index 8da45cea..9de2b8d2 100644 --- a/Sources/XCRemoteCache/Commands/SwiftFrontend/SwiftFrontendOrchestrator.swift +++ b/Sources/XCRemoteCache/Commands/SwiftFrontend/SwiftFrontendOrchestrator.swift @@ -21,25 +21,116 @@ import Foundation /// Manages the `swift-frontend` logic protocol SwiftFrontendOrchestrator { - /// Executes the criticial secion according to the required order + /// Executes the critical section according to the required order /// - Parameter criticalSection: the block that should be synchronized func run(criticalSection: () -> Void ) throws } /// The default orchestrator that manages the order or swift-frontend invocations -/// For emit-module (the "first" process) action, it locks a shared file between all swift-frontend invcations, -/// verifies that the mocking can be done and continues the mocking/fallbacking along the lock release -/// For the compilation action, tries to ackquire a lock and waits until the "emit-module" makes a decision +/// For emit-module (the "first" process) action, it locks a shared file between all swift-frontend invocations, +/// verifies that the mocking can be done and continues the mocking/fall-backing along the lock release +/// For the compilation action, tries to acquire a lock and waits until the "emit-module" makes a decision /// if the compilation should be skipped and a "mocking" should used instead class CommonSwiftFrontendOrchestrator { + /// Content saved to the shared file + /// Safe to use forced unwrapping + private static let emitModuleContent = "done".data(using: .utf8)! + + enum Action { + case emitModule + case compile + } private let mode: SwiftcContext.SwiftcMode + private let action: Action + private let lockAccessor: ExclusiveFileAccessor + private let maxLockTimeout: TimeInterval - init(mode: SwiftcContext.SwiftcMode) { + init( + mode: SwiftcContext.SwiftcMode, + action: Action, + lockAccessor: ExclusiveFileAccessor, + maxLockTimeout: TimeInterval + ) { self.mode = mode + self.action = action + self.lockAccessor = lockAccessor + self.maxLockTimeout = maxLockTimeout } func run(criticalSection: () throws -> Void) throws { - // TODO: implement synchronization in a separate PR - try criticalSection() + guard case .consumer(commit: .available) = mode else { + // no need to lock anything - just allow fallbacking to the `swiftc or swift-frontend` + // for a producer or a consumer where RC is disabled (we have already caught the + // cache miss) + try criticalSection() + return + } + try executeMockAttemp(criticalSection: criticalSection) + } + + private func executeMockAttemp(criticalSection: () throws -> Void) throws { + switch action { + case .emitModule: + try validateEmitModuleStep(criticalSection: criticalSection) + case .compile: + try waitForEmitModuleLock(criticalSection: criticalSection) + } + } + + + /// For emit-module, wrap the critical section with the shared lock so other processes (compilation) + /// have to wait until emit-module finishes + /// Once the emit-module is done, the "magical" string is saved to the file and the lock is released + /// + /// Note: The design of wrapping the entire "emit-module" has a small performance downside if inside + /// the critical section, the code realizes that remote cache cannot be used + /// (in practice - a new file has been added) + /// None of compilation process (so with '-c' args) can continue until the entire emit-module logic finishes + /// Because it is expected to happen not that often and emit-module is usually quite fast, this makes the + /// implementation way simpler. If we ever want to optimize it, we should release the lock as early + /// as we know, the remote cache cannot be used. Then all other compilation process (-c) can run + /// in parallel with emit-module + private func validateEmitModuleStep(criticalSection: () throws -> Void) throws { + debugLog("starting the emit-module step: locking") + try lockAccessor.exclusiveAccess { handle in + debugLog("starting the emit-module step: locked") + // writing to the file content proactively - incase the critical section never returns + // (in case of a fallback to the local compilation), all awaiting swift-frontend processes + // will be immediately unblocked + handle.write(Self.self.emitModuleContent) + try criticalSection() + debugLog("lock file emit-module criticial end") + } + } + + /// Locks a shared file in a loop until its content is non-empty - meaning the "parent" emit-module + /// has already finished + private func waitForEmitModuleLock(criticalSection: () throws -> Void) throws { + // emit-module process should really quickly obtain a lock (it is always invoked + // by Xcode as a first process) + var executed = false + let startingDate = Date() + while !executed { + debugLog("lock file compilation trying to acquire a lock ....") + try lockAccessor.exclusiveAccess { handle in + if !handle.availableData.isEmpty { + // the file is not empty so the emit-module process is done with the "check" + debugLog("swift-frontend lock file is unlocked for compilation") + try criticalSection() + executed = true + } else { + debugLog("swift-frontend lock file is not ready for compilation") + } + } + // When a max locking time is achieved, execute anyway + if !executed && Date().timeIntervalSince(startingDate) > self.maxLockTimeout { + errorLog(""" + Executing command \(action) without lock synchronization. That may be cause by the\ + crashed or extremely long emit-module. Contact XCRemoteCache authors about this error. + """) + try criticalSection() + executed = true + } + } } } diff --git a/Sources/XCRemoteCache/Commands/SwiftFrontend/XCSwiftFrontend.swift b/Sources/XCRemoteCache/Commands/SwiftFrontend/XCSwiftFrontend.swift index 64b26dc7..28255a02 100644 --- a/Sources/XCRemoteCache/Commands/SwiftFrontend/XCSwiftFrontend.swift +++ b/Sources/XCRemoteCache/Commands/SwiftFrontend/XCSwiftFrontend.swift @@ -55,6 +55,26 @@ public class XCSwiftFrontend: XCSwiftAbstract { } override public func run() throws { - // TODO: implement in a follow-up PR + do { + let (_, context) = try buildContext() + + let frontendContext = try SwiftFrontendContext(context, env: env) + let sharedLock = ExclusiveFile(frontendContext.invocationLockFile, mode: .override) + + let action: CommonSwiftFrontendOrchestrator.Action = inputArgs.emitModule ? .emitModule : .compile + let swiftFrontendOrchestrator = CommonSwiftFrontendOrchestrator( + mode: context.mode, + action: action, + lockAccessor: sharedLock, + maxLockTimeout: Self.self.MaxLockingTimeout + ) + + try swiftFrontendOrchestrator.run(criticalSection: super.run) + } catch { + // Splitting into 2 invocations as os_log truncates a massage + defaultLog("Cannot correctly orchestrate the \(command) with params \(inputArgs)") + defaultLog("Cannot correctly orchestrate error: \(error)") + throw error + } } } diff --git a/Sources/XCRemoteCache/Dependencies/CacheModeController.swift b/Sources/XCRemoteCache/Dependencies/CacheModeController.swift index db8871c3..8768917d 100644 --- a/Sources/XCRemoteCache/Dependencies/CacheModeController.swift +++ b/Sources/XCRemoteCache/Dependencies/CacheModeController.swift @@ -48,6 +48,7 @@ class PhaseCacheModeController: CacheModeController { private let dependenciesWriter: DependenciesWriter private let dependenciesReader: DependenciesReader private let markerWriter: MarkerWriter + private let llbuildLockFile: URL private let fileManager: FileManager init( @@ -59,6 +60,7 @@ class PhaseCacheModeController: CacheModeController { dependenciesWriter: (URL, FileManager) -> DependenciesWriter, dependenciesReader: (URL, FileManager) -> DependenciesReader, markerWriter: (URL, FileManager) -> MarkerWriter, + llbuildLockFile: URL, fileManager: FileManager ) { @@ -69,10 +71,12 @@ class PhaseCacheModeController: CacheModeController { let discoveryURL = tempDir.appendingPathComponent(phaseDependencyPath) self.dependenciesWriter = dependenciesWriter(discoveryURL, fileManager) self.dependenciesReader = dependenciesReader(discoveryURL, fileManager) + self.llbuildLockFile = llbuildLockFile self.markerWriter = markerWriter(modeMarker, fileManager) } func enable(allowedInputFiles: [URL], dependencies: [URL]) throws { + try cleanupLlBuildLock() // marker file contains filepaths that contribute to the build products // and should invalidate all other target steps (swiftc,libtool etc.) let targetSensitiveFiles = dependencies + [modeMarker, Self.xcodeSelectLink] @@ -84,6 +88,7 @@ class PhaseCacheModeController: CacheModeController { } func disable() throws { + try cleanupLlBuildLock() guard !forceCached else { throw PhaseCacheModeControllerError.cannotUseRemoteCacheForForcedCacheMode } @@ -114,4 +119,20 @@ class PhaseCacheModeController: CacheModeController { } return false } + + // cleanup the build lock file (if exists) as the very last step of this controller + // this is just a non-critical cleanup step to not leave {{LLBUILD_BUILD_ID}}.lock + // files in $TARGET_TEMP_DIR. It is expected that both prebuild and postbuild will + // invoke it, to ensure: + // - swift-frontend synchronization is done per-target build + // - no .lock leftover files + private func cleanupLlBuildLock() throws { + if fileManager.fileExists(atPath: llbuildLockFile.path) { + do { + try fileManager.removeItem(at: llbuildLockFile) + } catch { + printWarning("Removing llbuild lock at \(llbuildLockFile.path) failed. Error: \(error)") + } + } + } } diff --git a/Sources/xcswift-frontend/XCSwiftcFrontendMain.swift b/Sources/xcswift-frontend/XCSwiftcFrontendMain.swift index ec5a82ac..5079831e 100644 --- a/Sources/xcswift-frontend/XCSwiftcFrontendMain.swift +++ b/Sources/xcswift-frontend/XCSwiftcFrontendMain.swift @@ -29,7 +29,16 @@ public class XCSwiftcFrontendMain { // swiftlint:disable:next function_body_length cyclomatic_complexity public func main() { let env = ProcessInfo.processInfo.environment - let command = ProcessInfo().processName + // Do not invoke raw swift-frontend because that would lead to the infinite loop + // swift-frontent -> xcswift-frontent -> swift-frontent + // + // Note: Returning the `swiftc` executaion here because it is possible to pass all arguments + // from swift-frontend to `swiftc` and swiftc will be able to redirect to swift-frontend + // (because the first argument is `-frontend`). If that is not a case (might change in + // future swift compiler versions), invoke swift-frontend from the Xcode, but that introduces + // a limitation that disallows custom toolchains in Xcode: + // $DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/bin/{ ProcessInfo().processName} + let command = "swiftc" let args = ProcessInfo().arguments var compile = false var emitModule = false @@ -101,7 +110,7 @@ public class XCSwiftcFrontendMain { docPath: docPath, supplementaryOutputFileMap: supplementaryOutputFileMap ) - // swift-frontened is first invoked with some "probing" args like + // swift-frontend is first invoked with some "probing" args like // -print-target-info guard emitModule != compile else { runFallback(envs: env) diff --git a/Tests/XCRemoteCacheTests/Commands/PostbuildContextTests.swift b/Tests/XCRemoteCacheTests/Commands/PostbuildContextTests.swift index be78500b..097567fc 100644 --- a/Tests/XCRemoteCacheTests/Commands/PostbuildContextTests.swift +++ b/Tests/XCRemoteCacheTests/Commands/PostbuildContextTests.swift @@ -28,7 +28,7 @@ class PostbuildContextTests: FileXCTestCase { "TARGET_TEMP_DIR": "TARGET_TEMP_DIR", "DERIVED_FILE_DIR": "DERIVED_FILE_DIR", "ARCHS": "x86_64", - "OBJECT_FILE_DIR_normal": "/OBJECT_FILE_DIR_normal" , + "OBJECT_FILE_DIR_normal": "/OBJECT_FILE_DIR_normal", "CONFIGURATION": "CONFIGURATION", "PLATFORM_NAME": "PLATFORM_NAME", "XCODE_PRODUCT_BUILD_VERSION": "XCODE_PRODUCT_BUILD_VERSION", @@ -45,6 +45,7 @@ class PostbuildContextTests: FileXCTestCase { "DERIVED_SOURCES_DIR": "DERIVED_SOURCES_DIR", "CURRENT_VARIANT": "normal", "PUBLIC_HEADERS_FOLDER_PATH": "/usr/local/include", + "LLBUILD_BUILD_ID": "1", ] override func setUpWithError() throws { @@ -186,4 +187,17 @@ class PostbuildContextTests: FileXCTestCase { XCTAssertFalse(context.disabled) } + + func testFailsIfLlBuildIdEnvIsMissing() throws { + var envs = Self.SampleEnvs + envs.removeValue(forKey: "LLBUILD_BUILD_ID") + + XCTAssertThrowsError(try PostbuildContext(config, env: envs)) + } + + func testBuildsLockValidFileUrl() throws { + let context = try PostbuildContext(config, env: Self.SampleEnvs) + + XCTAssertEqual(context.llbuildIdLockFile, "TARGET_TEMP_DIR/1.lock") + } } diff --git a/Tests/XCRemoteCacheTests/Commands/PostbuildTests.swift b/Tests/XCRemoteCacheTests/Commands/PostbuildTests.swift index 31c34595..9c9f39b8 100644 --- a/Tests/XCRemoteCacheTests/Commands/PostbuildTests.swift +++ b/Tests/XCRemoteCacheTests/Commands/PostbuildTests.swift @@ -57,7 +57,8 @@ class PostbuildTests: FileXCTestCase { overlayHeadersPath: "", irrelevantDependenciesPaths: [], publicHeadersFolderPath: nil, - disabled: false + disabled: false, + llbuildIdLockFile: "/file" ) private var network = RemoteNetworkClientImpl( NetworkClientFake(fileManager: .default), diff --git a/Tests/XCRemoteCacheTests/Commands/PrebuildContextTests.swift b/Tests/XCRemoteCacheTests/Commands/PrebuildContextTests.swift new file mode 100644 index 00000000..cd6318e4 --- /dev/null +++ b/Tests/XCRemoteCacheTests/Commands/PrebuildContextTests.swift @@ -0,0 +1,72 @@ +// Copyright (c) 2023 Spotify AB. +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +@testable import XCRemoteCache +import XCTest + +class PrebuildContextTests: FileXCTestCase { + private var config: XCRemoteCacheConfig! + private var remoteCommitFile: URL! + private static let SampleEnvs = [ + "TARGET_NAME": "TARGET_NAME", + "TARGET_TEMP_DIR": "TARGET_TEMP_DIR", + "DERIVED_FILE_DIR": "DERIVED_FILE_DIR", + "ARCHS": "x86_64", + "OBJECT_FILE_DIR_normal": "/OBJECT_FILE_DIR_normal", + "CONFIGURATION": "CONFIGURATION", + "PLATFORM_NAME": "PLATFORM_NAME", + "XCODE_PRODUCT_BUILD_VERSION": "XCODE_PRODUCT_BUILD_VERSION", + "TARGET_BUILD_DIR": "TARGET_BUILD_DIR", + "PRODUCT_MODULE_NAME": "PRODUCT_MODULE_NAME", + "EXECUTABLE_PATH": "EXECUTABLE_PATH", + "SRCROOT": "SRCROOT", + "DEVELOPER_DIR": "DEVELOPER_DIR", + "MACH_O_TYPE": "MACH_O_TYPE", + "DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT": "DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT", + "DWARF_DSYM_FOLDER_PATH": "DWARF_DSYM_FOLDER_PATH", + "DWARF_DSYM_FILE_NAME": "DWARF_DSYM_FILE_NAME", + "BUILT_PRODUCTS_DIR": "BUILT_PRODUCTS_DIR", + "DERIVED_SOURCES_DIR": "DERIVED_SOURCES_DIR", + "CURRENT_VARIANT": "normal", + "PUBLIC_HEADERS_FOLDER_PATH": "/usr/local/include", + "LLBUILD_BUILD_ID": "1", + ] + + override func setUpWithError() throws { + try super.setUpWithError() + let workingDir = try prepareTempDir() + remoteCommitFile = workingDir.appendingPathComponent("arc.rc") + _ = workingDir.appendingPathComponent("mpo") + config = XCRemoteCacheConfig(remoteCommitFile: remoteCommitFile.path, sourceRoot: workingDir.path) + config.recommendedCacheAddress = "http://test.com" + } + + func testFailsIfLlBuildIdEnvIsMissing() throws { + var envs = Self.SampleEnvs + envs.removeValue(forKey: "LLBUILD_BUILD_ID") + + XCTAssertThrowsError(try PrebuildContext(config, env: envs)) + } + + func testBuildsLockValidFileUrl() throws { + let context = try PrebuildContext(config, env: Self.SampleEnvs) + + XCTAssertEqual(context.llbuildIdLockFile, "TARGET_TEMP_DIR/1.lock") + } +} diff --git a/Tests/XCRemoteCacheTests/Commands/PrebuildTests.swift b/Tests/XCRemoteCacheTests/Commands/PrebuildTests.swift index 84bcea4b..56e08e6e 100644 --- a/Tests/XCRemoteCacheTests/Commands/PrebuildTests.swift +++ b/Tests/XCRemoteCacheTests/Commands/PrebuildTests.swift @@ -20,6 +20,7 @@ @testable import XCRemoteCache import XCTest +// swiftlint:disable file_length // swiftlint:disable:next type_body_length class PrebuildTests: FileXCTestCase { @@ -52,6 +53,13 @@ class PrebuildTests: FileXCTestCase { remoteNetwork = RemoteNetworkClientImpl(network, URLBuilderFake(remoteCacheURL)) remapper = DependenciesRemapperFake(baseURL: URL(fileURLWithPath: "/")) metaReader = JsonMetaReader(fileAccessor: FileManager.default) + setupNonCachedContext() + setupCachedContext() + organizer = ArtifactOrganizerFake(artifactRoot: artifactsRoot, unzippedExtension: "unzip") + globalCacheSwitcher = InMemoryGlobalCacheSwitcher() + } + + private func setupNonCachedContext() { contextNonCached = PrebuildContext( targetTempDir: sampleURL, productsDir: sampleURL, @@ -64,8 +72,12 @@ class PrebuildTests: FileXCTestCase { turnOffRemoteCacheOnFirstTimeout: true, targetName: "", overlayHeadersPath: "", - disabled: false + disabled: false, + llbuildIdLockFile: "/tmp/lock" ) + } + + private func setupCachedContext() { contextCached = PrebuildContext( targetTempDir: sampleURL, productsDir: sampleURL, @@ -78,10 +90,9 @@ class PrebuildTests: FileXCTestCase { turnOffRemoteCacheOnFirstTimeout: true, targetName: "", overlayHeadersPath: "", - disabled: false + disabled: false, + llbuildIdLockFile: "/tmp/lock" ) - organizer = ArtifactOrganizerFake(artifactRoot: artifactsRoot, unzippedExtension: "unzip") - globalCacheSwitcher = InMemoryGlobalCacheSwitcher() } override func tearDownWithError() throws { @@ -244,7 +255,8 @@ class PrebuildTests: FileXCTestCase { turnOffRemoteCacheOnFirstTimeout: true, targetName: "", overlayHeadersPath: "", - disabled: false + disabled: false, + llbuildIdLockFile: "/tmp/lock" ) let prebuild = Prebuild( @@ -276,7 +288,8 @@ class PrebuildTests: FileXCTestCase { turnOffRemoteCacheOnFirstTimeout: true, targetName: "", overlayHeadersPath: "", - disabled: false + disabled: false, + llbuildIdLockFile: "/tmp/lock" ) metaContent = try generateMeta(fingerprint: generator.generate(), filekey: "1") let downloadedArtifactPackage = artifactsRoot.appendingPathComponent("1") @@ -340,7 +353,8 @@ class PrebuildTests: FileXCTestCase { turnOffRemoteCacheOnFirstTimeout: false, targetName: "", overlayHeadersPath: "", - disabled: false + disabled: false, + llbuildIdLockFile: "/tmp/lock" ) try globalCacheSwitcher.enable(sha: "1") let prebuild = Prebuild( @@ -372,7 +386,8 @@ class PrebuildTests: FileXCTestCase { turnOffRemoteCacheOnFirstTimeout: true, targetName: "", overlayHeadersPath: "", - disabled: true + disabled: true, + llbuildIdLockFile: "/tmp/lock" ) let prebuild = Prebuild( diff --git a/Tests/XCRemoteCacheTests/Commands/SwiftFrontend/SwiftFrontendContextTests.swift b/Tests/XCRemoteCacheTests/Commands/SwiftFrontend/SwiftFrontendContextTests.swift new file mode 100644 index 00000000..d29a4339 --- /dev/null +++ b/Tests/XCRemoteCacheTests/Commands/SwiftFrontend/SwiftFrontendContextTests.swift @@ -0,0 +1,56 @@ +// Copyright (c) 2023 Spotify AB. +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +@testable import XCRemoteCache +import XCTest + + +class SwiftFrontendContextTests: XCTestCase { + + private var swiftcContext: SwiftcContext! + private let targetTempDir: URL = "/temp" + + override func setUp() async throws { + swiftcContext = try SwiftcContext( + config: .init(sourceRoot: ""), + input: .init( + objcHeaderOutput: "", + moduleName: "", + modulePathOutput: "\(targetTempDir.path)/Objects-normal/$ARCH/some.file", + filemap: "", + target: "", + fileList: "" + ) + ) + } + + func testBuildsBuildLockInTargetTemp() throws { + let env = [ + "LLBUILD_BUILD_ID": "1", + ] + + let frontendContext = try SwiftFrontendContext(swiftcContext, env: env) + + XCTAssertEqual(frontendContext.invocationLockFile, targetTempDir.appendingPathComponent("1.lock")) + } + + func testInitializerFailsIfLlBuildIdIsMissingInEnv() throws { + XCTAssertThrowsError(try SwiftFrontendContext(swiftcContext, env: [:])) + } +} diff --git a/Tests/XCRemoteCacheTests/Commands/SwiftFrontendOrchestratorTests.swift b/Tests/XCRemoteCacheTests/Commands/SwiftFrontendOrchestratorTests.swift new file mode 100644 index 00000000..386eb583 --- /dev/null +++ b/Tests/XCRemoteCacheTests/Commands/SwiftFrontendOrchestratorTests.swift @@ -0,0 +1,134 @@ +// Copyright (c) 2023 Spotify AB. +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +@testable import XCRemoteCache +import XCTest + + +final class SwiftFrontendOrchestratorTests: FileXCTestCase { + private let prohibitedAccessor = DisallowedExclusiveFileAccessor() + private var nonEmptyFile: URL! + private var emptyFile: URL! + private let maxLocking: TimeInterval = 10 + + override func setUp() async throws { + nonEmptyFile = try prepareTempDir().appendingPathComponent("lock.lock") + try fileManager.write(toPath: nonEmptyFile.path, contents: "done".data(using: .utf8)) + emptyFile = try prepareTempDir().appendingPathComponent("lock_empty.lock") + try fileManager.write(toPath: emptyFile.path, contents: .init()) + } + + func testRunsCriticalSectionImmediatelyForProducer() throws { + let orchestrator = CommonSwiftFrontendOrchestrator( + mode: .producer, + action: .compile, + lockAccessor: prohibitedAccessor, + maxLockTimeout: maxLocking + ) + + var invoked = false + try orchestrator.run { + invoked = true + } + XCTAssertTrue(invoked) + } + + func testRunsCriticalSectionImmediatelyForDisabledConsumer() throws { + let orchestrator = CommonSwiftFrontendOrchestrator( + mode: .consumer(commit: .unavailable), + action: .compile, + lockAccessor: prohibitedAccessor, + maxLockTimeout: maxLocking + ) + + var invoked = false + try orchestrator.run { + invoked = true + } + XCTAssertTrue(invoked) + } + + func testRunsEmitModuleLogicInExclusiveLock() throws { + let lock = FakeExclusiveFileAccessor() + let orchestrator = CommonSwiftFrontendOrchestrator( + mode: .consumer(commit: .available(commit: "")), + action: .emitModule, + lockAccessor: lock, + maxLockTimeout: maxLocking + ) + + var invoked = false + try orchestrator.run { + XCTAssertTrue(lock.isLocked) + invoked = true + } + XCTAssertTrue(invoked) + } + + func testCompilationInvokesCriticalSectionOnlyForNonEmptyLockFile() throws { + let lock = FakeExclusiveFileAccessor(pattern: [.empty, .nonEmptyForRead(nonEmptyFile)]) + let orchestrator = CommonSwiftFrontendOrchestrator( + mode: .consumer(commit: .available(commit: "")), + action: .compile, + lockAccessor: lock, + maxLockTimeout: maxLocking + ) + + var invoked = false + try orchestrator.run { + XCTAssertTrue(lock.isLocked) + invoked = true + } + XCTAssertTrue(invoked) + } + + func testExecutesActionWithoutLockIfLockingFileIsEmptyForALongTime() throws { + let lock = FakeExclusiveFileAccessor(pattern: []) + let orchestrator = CommonSwiftFrontendOrchestrator( + mode: .consumer(commit: .available(commit: "")), + action: .compile, + lockAccessor: lock, + maxLockTimeout: 0 + ) + + var invoked = false + try orchestrator.run { + XCTAssertFalse(lock.isLocked) + invoked = true + } + XCTAssertTrue(invoked) + } + + func testExecutesCriticalSectionAfterWriting() throws { + let lock = FakeExclusiveFileAccessor(pattern: [.nonEmptyForWrite(emptyFile)]) + let orchestrator = CommonSwiftFrontendOrchestrator( + mode: .consumer(commit: .available(commit: "")), + action: .emitModule, + lockAccessor: lock, + maxLockTimeout: 0 + ) + + var invoked = false + try orchestrator.run { + XCTAssertEqual(fileManager.contents(atPath: emptyFile.path), "done".data(using: .utf8)) + invoked = true + } + XCTAssertTrue(invoked) + } +} diff --git a/Tests/XCRemoteCacheTests/Dependencies/PhaseCacheModeControllerTests.swift b/Tests/XCRemoteCacheTests/Dependencies/PhaseCacheModeControllerTests.swift index 3ad44a4b..2e08cee3 100644 --- a/Tests/XCRemoteCacheTests/Dependencies/PhaseCacheModeControllerTests.swift +++ b/Tests/XCRemoteCacheTests/Dependencies/PhaseCacheModeControllerTests.swift @@ -20,8 +20,14 @@ @testable import XCRemoteCache import XCTest -class PhaseCacheModeControllerTests: XCTestCase { +class PhaseCacheModeControllerTests: FileXCTestCase { + private var rootDir: URL! private let sampleURL = URL(fileURLWithPath: "") + private var simpleController: PhaseCacheModeController! + + override func setUp() async throws { + rootDir = try prepareTempDir() + } func testDisablesForSpecifiedSha() { let dependenciesReader = DependenciesReaderFake(dependencies: ["skipForSha": ["dbd123"]]) @@ -34,6 +40,7 @@ class PhaseCacheModeControllerTests: XCTestCase { dependenciesWriter: FileDependenciesWriter.init, dependenciesReader: { _, _ in dependenciesReader }, markerWriter: FileMarkerWriter.init, + llbuildLockFile: "/file", fileManager: FileManager.default ) @@ -51,6 +58,7 @@ class PhaseCacheModeControllerTests: XCTestCase { dependenciesWriter: FileDependenciesWriter.init, dependenciesReader: { _, _ in dependenciesReader }, markerWriter: FileMarkerWriter.init, + llbuildLockFile: "/tmp/lock", fileManager: FileManager.default ) @@ -68,6 +76,7 @@ class PhaseCacheModeControllerTests: XCTestCase { dependenciesWriter: FileDependenciesWriter.init, dependenciesReader: { _, _ in dependenciesReader }, markerWriter: FileMarkerWriter.init, + llbuildLockFile: "/tmp/lock", fileManager: FileManager.default ) @@ -85,6 +94,7 @@ class PhaseCacheModeControllerTests: XCTestCase { dependenciesWriter: { _, _ in dependenciesWriter }, dependenciesReader: { _, _ in DependenciesReaderFake(dependencies: [:]) }, markerWriter: { _, _ in MarkerWriterSpy() }, + llbuildLockFile: "/tmp/lock", fileManager: FileManager.default ) @@ -105,6 +115,7 @@ class PhaseCacheModeControllerTests: XCTestCase { dependenciesWriter: { _, _ in dependenciesWriter }, dependenciesReader: { _, _ in DependenciesReaderFake(dependencies: [:]) }, markerWriter: { _, _ in MarkerWriterSpy() }, + llbuildLockFile: "/tmp/lock", fileManager: FileManager.default ) @@ -125,6 +136,7 @@ class PhaseCacheModeControllerTests: XCTestCase { dependenciesWriter: { _, _ in DependenciesWriterSpy() }, dependenciesReader: { _, _ in DependenciesReaderFake(dependencies: [:]) }, markerWriter: { _, _ in markerWriter }, + llbuildLockFile: "/tmp/lock", fileManager: FileManager.default ) @@ -142,6 +154,7 @@ class PhaseCacheModeControllerTests: XCTestCase { dependenciesWriter: { _, _ in DependenciesWriterSpy() }, dependenciesReader: { _, _ in DependenciesReaderFake(dependencies: [:]) }, markerWriter: { _, _ in MarkerWriterSpy() }, + llbuildLockFile: "/tmp/lock", fileManager: FileManager.default ) @@ -163,6 +176,7 @@ class PhaseCacheModeControllerTests: XCTestCase { dependenciesWriter: { _, _ in dependenciesWriter }, dependenciesReader: { _, _ in DependenciesReaderFake(dependencies: [:]) }, markerWriter: { _, _ in markerWriterSpy }, + llbuildLockFile: "/tmp/lock", fileManager: FileManager.default ) @@ -174,4 +188,48 @@ class PhaseCacheModeControllerTests: XCTestCase { } XCTAssertEqual(Set(deps), expectedMarkerFiles) } + + func testDeletesLockOnEnable() throws { + let lockURL = rootDir.appendingPathComponent("1.lock") + try fileManager.spt_createEmptyFile(lockURL) + let dependenciesReader = DependenciesReaderFake(dependencies: [:]) + simpleController = PhaseCacheModeController( + tempDir: sampleURL, + mergeCommitFile: sampleURL, + phaseDependencyPath: "", + markerPath: "", + forceCached: false, + dependenciesWriter: { _, _ in DependenciesWriterSpy() }, + dependenciesReader: { _, _ in dependenciesReader }, + markerWriter: { _, _ in MarkerWriterSpy() }, + llbuildLockFile: lockURL, + fileManager: FileManager.default + ) + + try simpleController.enable(allowedInputFiles: [], dependencies: []) + + XCTAssertFalse(fileManager.fileExists(atPath: lockURL.path)) + } + + func testDeletesLockOnDisable() throws { + let lockURL = rootDir.appendingPathComponent("1.lock") + try fileManager.spt_createEmptyFile(lockURL) + let dependenciesReader = DependenciesReaderFake(dependencies: [:]) + simpleController = PhaseCacheModeController( + tempDir: sampleURL, + mergeCommitFile: sampleURL, + phaseDependencyPath: "", + markerPath: "", + forceCached: false, + dependenciesWriter: { _, _ in DependenciesWriterSpy() }, + dependenciesReader: { _, _ in dependenciesReader }, + markerWriter: { _, _ in MarkerWriterSpy() }, + llbuildLockFile: lockURL, + fileManager: FileManager.default + ) + + try simpleController.disable() + + XCTAssertFalse(fileManager.fileExists(atPath: lockURL.path)) + } } diff --git a/Tests/XCRemoteCacheTests/TestDoubles/DisallowedExclusiveFileAccessor.swift b/Tests/XCRemoteCacheTests/TestDoubles/DisallowedExclusiveFileAccessor.swift new file mode 100644 index 00000000..e366670a --- /dev/null +++ b/Tests/XCRemoteCacheTests/TestDoubles/DisallowedExclusiveFileAccessor.swift @@ -0,0 +1,28 @@ +// Copyright (c) 2023 Spotify AB. +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import Foundation +@testable import XCRemoteCache + +// FileAcccessor that fails if one wants to acquire a lock +class DisallowedExclusiveFileAccessor: ExclusiveFileAccessor { + func exclusiveAccess(block: (FileHandle) throws -> (T)) throws -> T { + throw "Invoked ProhibitedExclusiveFileAccessor" + } +} diff --git a/Tests/XCRemoteCacheTests/TestDoubles/FakeExclusiveFileAccessor.swift b/Tests/XCRemoteCacheTests/TestDoubles/FakeExclusiveFileAccessor.swift new file mode 100644 index 00000000..5e920be7 --- /dev/null +++ b/Tests/XCRemoteCacheTests/TestDoubles/FakeExclusiveFileAccessor.swift @@ -0,0 +1,59 @@ +// Copyright (c) 2023 Spotify AB. +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import Foundation +@testable import XCRemoteCache + +// Thread-unsafe, in-memory lock +class FakeExclusiveFileAccessor: ExclusiveFileAccessor { + private(set) var isLocked = false + private var pattern: [LockFileContent] + + enum LockFileContent { + case empty + case nonEmptyForRead(URL) + case nonEmptyForWrite(URL) + + func fileHandle() throws -> FileHandle { + switch self { + case .empty: return FileHandle.nullDevice + case .nonEmptyForRead(let url): return try FileHandle(forReadingFrom: url) + case .nonEmptyForWrite(let url): return try FileHandle(forWritingTo: url) + } + } + } + + init(pattern: [LockFileContent] = []) { + // keep in the reversed order to always pop + self.pattern = pattern.reversed() + } + + func exclusiveAccess(block: (FileHandle) throws -> (T)) throws -> T { + if isLocked { + throw "FakeExclusiveFileAccessor lock is already locked" + } + defer { + isLocked = false + } + isLocked = true + let fileHandle = try (pattern.popLast() ?? .empty).fileHandle() + return try block(fileHandle) + } + +} diff --git a/docs/design/ArchitecturalDesigns.md b/docs/design/ArchitecturalDesigns.md new file mode 100644 index 00000000..dc884188 --- /dev/null +++ b/docs/design/ArchitecturalDesigns.md @@ -0,0 +1,3 @@ +# Architectural designs + +1. [Swift Driver Integration](./SwiftDriverIntegration.md) diff --git a/docs/design/SwiftDriverIntegration.md b/docs/design/SwiftDriverIntegration.md new file mode 100644 index 00000000..02d842e0 --- /dev/null +++ b/docs/design/SwiftDriverIntegration.md @@ -0,0 +1,48 @@ +## Swift Driver Integration + +### Pre Swift Driver Integration + +Historically (prior to Xcode 14), Swift compilation step was invoked by Xcode as a single external process. Xcode was calling `swiftc` and passing all required parameters (like all input files, output destinations, header paths etc.), and reading its standard output to recognize the status/state of a compilation. Essentially, there were two build systems: "the big one" from Xcode and "small one" by Swift. + +That design was easy to mock in the XCRemoteCache, where the `xcswiftc` wrapper was first inspecting if the cached artifact can be reused (e.g. no new input `.swift` files were added to the list of compilation files) and based on that either continuing with the local compilation (cache miss) or mocking the compilation and existing early (cache hit). + +

+ + +

+ +### Swift Driver Integration Design + +With the upgraded design (aka Swift Driver Integration), Xcode splits the work into `n` subprocesses (when `n` is ~CPU), each responsible to compile a subset of files/actions. To align with that, XCRemoteCache meeds to specify a single place to identify if the cached artifact is applicable. `swift-frontend` has been picked for that - process responsible for module emitting. By reviewing Xcode's behavior, it has been found that this process is scheduled very early in the workflow timeline (with some approximation, we could say it is scheduled as a first step) so it seems as best candidate for the "pre-work". + +As the same executable `swift-frontend` is invoked multiple times for the same target (e.g. to emit module, multiple batches of compilation etc.), XCRemoteCaches uses a file lock-based synchronization. Each `xcswift-frontend` (the wrapper for `swift-frontend`) tries to acquire a unique lock file. The lock has a name `$LLBUILD_BUILD_ID.lock`, which is unique for each build, placed in the `Intermediate` directory. `xcswift-frontend` process reads its content to find if the "pre-work" from the emit-module has already been done - if not, it releases a lock a gives a way to other processes (presumably the "emit-module") to do the required work. As a lock file is unique per target and a build (it is actually unique per target compilation, placed in `TARGET_TEMP_DIR`), initially the file is empty. + +Note the emit module step holds a shared lock for the time of the entire process lifetime, so only once the "pre-work" is finished, all other `xcswift-frontend` processes can continue their job (with either noop or fallbacking to the `swift-frontend` in case a cache miss). Non emit-module steps (compilation steps) acquire a lock only for a very short period - to read the content of that file, thus multiple batches of compilation can run in parallel. + +

+ + +

+ + + +### Sample timelines + +#### Emit Module acquires a lock first (common) + +

+ + +

+ +#### A compilation step acquires a lock first (uncommon but possible) + +

+ + +

+ +### Other considerations/open questions + +* For mixed targets (ObjC&Swift), Xcode triggers `.m` compilation steps **after** the module emitting to ensure that the `-Swift.h` is available for clang compilation. That means, the synchronization algorithm will postpone any `clang` invocations until the Swift "pre-work" is done. Therefore, mixed targets should behave the same way as in the non Swift Driver Integration flow +* For the WMO (Whole Module Optimization) mode, all compilation steps are combined into a single `swift-frontend` process. As the emit-module step is still invoked first, the WMO flow build can be considered as a special case of the algorithm described above (where there is only one compilation invocation). Therefore, the presented algorithm will work for the WMO mode out of the box. diff --git a/docs/img/driver-dark.png b/docs/img/driver-dark.png new file mode 100644 index 00000000..3988bde4 Binary files /dev/null and b/docs/img/driver-dark.png differ diff --git a/docs/img/driver-scenario1-dark.png b/docs/img/driver-scenario1-dark.png new file mode 100644 index 00000000..639c3b15 Binary files /dev/null and b/docs/img/driver-scenario1-dark.png differ diff --git a/docs/img/driver-scenario1.png b/docs/img/driver-scenario1.png new file mode 100644 index 00000000..4f84cb65 Binary files /dev/null and b/docs/img/driver-scenario1.png differ diff --git a/docs/img/driver-scenario2-dark.png b/docs/img/driver-scenario2-dark.png new file mode 100644 index 00000000..b08d7c8d Binary files /dev/null and b/docs/img/driver-scenario2-dark.png differ diff --git a/docs/img/driver-scenario2.png b/docs/img/driver-scenario2.png new file mode 100644 index 00000000..acdc18b4 Binary files /dev/null and b/docs/img/driver-scenario2.png differ diff --git a/docs/img/driver.png b/docs/img/driver.png new file mode 100644 index 00000000..a06afe0c Binary files /dev/null and b/docs/img/driver.png differ diff --git a/docs/img/pre-driver-dark.png b/docs/img/pre-driver-dark.png new file mode 100644 index 00000000..ddce8f9e Binary files /dev/null and b/docs/img/pre-driver-dark.png differ diff --git a/docs/img/pre-driver.png b/docs/img/pre-driver.png new file mode 100644 index 00000000..dfc52681 Binary files /dev/null and b/docs/img/pre-driver.png differ diff --git a/docs/img/sample-driver-timeline.png b/docs/img/sample-driver-timeline.png new file mode 100644 index 00000000..4370b0ee Binary files /dev/null and b/docs/img/sample-driver-timeline.png differ diff --git a/e2eTests/StandaloneSampleApp/StandaloneApp.xcodeproj/project.pbxproj b/e2eTests/StandaloneSampleApp/StandaloneApp.xcodeproj/project.pbxproj index 327cb466..c731e489 100644 --- a/e2eTests/StandaloneSampleApp/StandaloneApp.xcodeproj/project.pbxproj +++ b/e2eTests/StandaloneSampleApp/StandaloneApp.xcodeproj/project.pbxproj @@ -376,7 +376,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "ditto \"${SCRIPT_INPUT_FILE_0}\" \"${SCRIPT_OUTPUT_FILE_0}\"\n[ -f \"${SCRIPT_INPUT_FILE_1}\" ] && ditto \"${SCRIPT_INPUT_FILE_1}\" \"${SCRIPT_OUTPUT_FILE_1}\" || rm \"${SCRIPT_OUTPUT_FILE_1}\"\n\n"; + shellScript = "ditto \"${SCRIPT_INPUT_FILE_0}\" \"${SCRIPT_OUTPUT_FILE_0}\"\n[ -f \"${SCRIPT_INPUT_FILE_1}\" ] && ditto \"${SCRIPT_INPUT_FILE_1}\" \"${SCRIPT_OUTPUT_FILE_1}\" || rm -f \"${SCRIPT_OUTPUT_FILE_1}\"\n\n"; }; /* End PBXShellScriptBuildPhase section */