diff --git a/Sources/Basics/Cancellator.swift b/Sources/Basics/Cancellator.swift index 7925e1297e8..b8ad51fea62 100644 --- a/Sources/Basics/Cancellator.swift +++ b/Sources/Basics/Cancellator.swift @@ -99,7 +99,7 @@ public final class Cancellator: Cancellable, Sendable { @discardableResult public func register(name: String, handler: @escaping CancellationHandler) -> RegistrationKey? { - if self.cancelling.get(default: false) { + if self.cancelling.get() { self.observabilityScope?.emit(debug: "not registering '\(name)' with terminator, termination in progress") return .none } diff --git a/Sources/Basics/Concurrency/AsyncProcess.swift b/Sources/Basics/Concurrency/AsyncProcess.swift index 3ca1cf19a90..793c35fc091 100644 --- a/Sources/Basics/Concurrency/AsyncProcess.swift +++ b/Sources/Basics/Concurrency/AsyncProcess.swift @@ -805,7 +805,7 @@ package final class AsyncProcess { package func waitUntilExit() throws -> AsyncProcessResult { let group = DispatchGroup() group.enter() - let resultBox = ThreadSafeBox>() + let resultBox = ThreadSafeBox?>() self.waitUntilExit { result in resultBox.put(result) group.leave() diff --git a/Sources/Basics/Concurrency/ConcurrencyHelpers.swift b/Sources/Basics/Concurrency/ConcurrencyHelpers.swift index 2837bea4b95..581f3c92916 100644 --- a/Sources/Basics/Concurrency/ConcurrencyHelpers.swift +++ b/Sources/Basics/Concurrency/ConcurrencyHelpers.swift @@ -29,7 +29,7 @@ public enum Concurrency { public func unsafe_await(_ body: @Sendable @escaping () async -> T) -> T { let semaphore = DispatchSemaphore(value: 0) - let box = ThreadSafeBox() + let box = ThreadSafeBox() Task { let localValue: T = await body() box.mutate { _ in localValue } diff --git a/Sources/Basics/Concurrency/ThreadSafeBox.swift b/Sources/Basics/Concurrency/ThreadSafeBox.swift index 272415bcaaa..c5b04ad6dca 100644 --- a/Sources/Basics/Concurrency/ThreadSafeBox.swift +++ b/Sources/Basics/Concurrency/ThreadSafeBox.swift @@ -12,135 +12,211 @@ import class Foundation.NSLock -/// Thread-safe value boxing structure +/// Thread-safe value boxing structure that provides synchronized access to a wrapped value. @dynamicMemberLookup public final class ThreadSafeBox { - private var underlying: Value? + private var underlying: Value private let lock = NSLock() - public init() {} - + /// Creates a new thread-safe box with the given initial value. + /// + /// - Parameter seed: The initial value to store in the box. public init(_ seed: Value) { self.underlying = seed } - public func mutate(body: (Value?) throws -> Value?) rethrows { + /// Atomically mutates the stored value by applying a transformation function. + /// + /// The transformation function receives the current value and returns a new value + /// to replace it. The entire operation is performed under a lock to ensure atomicity. + /// + /// - Parameter body: A closure that takes the current value and returns a new value. + /// - Throws: Any error thrown by the transformation function. + public func mutate(body: (Value) throws -> Value) rethrows { try self.lock.withLock { let value = try body(self.underlying) self.underlying = value } } - - public func mutate(body: (inout Value?) throws -> ()) rethrows { + + /// Atomically mutates the stored value by applying an in-place transformation. + /// + /// The transformation function receives an inout reference to the current value, + /// allowing direct modification. The entire operation is performed under a lock + /// to ensure atomicity. + /// + /// - Parameter body: A closure that receives an inout reference to the current value. + /// - Throws: Any error thrown by the transformation function. + public func mutate(body: (inout Value) throws -> Void) rethrows { try self.lock.withLock { try body(&self.underlying) } } - @discardableResult - public func memoize(body: () throws -> Value) rethrows -> Value { - if let value = self.get() { - return value - } - let value = try body() + /// Atomically retrieves the current value from the box. + /// + /// - Returns: A copy of the current value stored in the box. + public func get() -> Value { self.lock.withLock { - self.underlying = value + self.underlying } - return value } - @discardableResult - public func memoize(body: () async throws -> Value) async rethrows -> Value { - if let value = self.get() { - return value - } - let value = try await body() + /// Atomically replaces the current value with a new value. + /// + /// - Parameter newValue: The new value to store in the box. + public func put(_ newValue: Value) { self.lock.withLock { - self.underlying = value + self.underlying = newValue } - return value } - public func clear() { + /// Provides thread-safe read-only access to properties of the wrapped value. + /// + /// This subscript allows you to access properties of the wrapped value using + /// dot notation while maintaining thread safety. + /// + /// - Parameter keyPath: A key path to a property of the wrapped value. + /// - Returns: The value of the specified property. + public subscript(dynamicMember keyPath: KeyPath) -> T { self.lock.withLock { - self.underlying = nil + self.underlying[keyPath: keyPath] } } - public func get() -> Value? { - self.lock.withLock { - self.underlying + /// Provides thread-safe read-write access to properties of the wrapped value. + /// + /// - Parameter keyPath: A writable key path to a property of the wrapped value. + /// - Returns: The value of the specified property when getting. + public subscript(dynamicMember keyPath: WritableKeyPath) -> T { + get { + self.lock.withLock { + self.underlying[keyPath: keyPath] + } + } + set { + self.lock.withLock { + self.underlying[keyPath: keyPath] = newValue + } } } +} - public func get(default: Value) -> Value { - self.lock.withLock { - self.underlying ?? `default` - } +// Extension for optional values to support empty initialization +extension ThreadSafeBox { + /// Creates a new thread-safe box initialized with nil for optional value types. + /// + /// This convenience initializer is only available when the wrapped value type is optional. + public convenience init() where Value == Wrapped? { + self.init(nil) } - public func put(_ newValue: Value) { + /// Takes the stored optional value, setting it to nil. + /// - Returns: The previously stored value, or nil if none was present. + public func takeValue() -> Value where Value == Wrapped? { self.lock.withLock { - self.underlying = newValue + guard let value = self.underlying else { return nil } + self.underlying = nil + return value } } - public func takeValue() -> Value where U? == Value { + /// Atomically sets the stored optional value to nil. + /// + /// This method is only available when the wrapped value type is optional. + public func clear() where Value == Wrapped? { self.lock.withLock { - guard let value = self.underlying else { return nil } self.underlying = nil - return value } } - public subscript(dynamicMember keyPath: KeyPath) -> T? { + /// Atomically retrieves the stored value, returning a default if nil. + /// + /// This method is only available when the wrapped value type is optional. + /// + /// - Parameter defaultValue: The value to return if the stored value is nil. + /// - Returns: The stored value if not nil, otherwise the default value. + public func get(default defaultValue: Wrapped) -> Wrapped where Value == Wrapped? { self.lock.withLock { - self.underlying?[keyPath: keyPath] + self.underlying ?? defaultValue } } - public subscript(dynamicMember keyPath: WritableKeyPath) -> T? { - get { - self.lock.withLock { - self.underlying?[keyPath: keyPath] + /// Atomically computes and caches a value if not already present. + /// + /// If the box already contains a non-nil value, that value is returned immediately. + /// Otherwise, the provided closure is executed to compute the value, which is then + /// stored and returned. This method is only available when the wrapped value type is optional. + /// + /// - Parameter body: A closure that computes the value to store if none exists. + /// - Returns: The cached value or the newly computed value. + /// - Throws: Any error thrown by the computation closure. + @discardableResult + public func memoize(body: () throws -> Wrapped) rethrows -> Wrapped where Value == Wrapped? { + try self.lock.withLock { + if let value = self.underlying { + return value } + let value = try body() + self.underlying = value + return value } - set { - self.lock.withLock { - if var value = self.underlying { - value[keyPath: keyPath] = newValue - } + } + + /// Atomically computes and caches an optional value if not already present. + /// + /// If the box already contains a non-nil value, that value is returned immediately. + /// Otherwise, the provided closure is executed to compute the value, which is then + /// stored and returned. This method is only available when the wrapped value type is optional. + /// + /// If the returned value is `nil` subsequent calls to `memoize` or `memoizeOptional` will + /// re-execute the closure. + /// + /// - Parameter body: A closure that computes the optional value to store if none exists. + /// - Returns: The cached value or the newly computed value (which may be nil). + /// - Throws: Any error thrown by the computation closure. + @discardableResult + public func memoizeOptional(body: () throws -> Wrapped?) rethrows -> Wrapped? where Value == Wrapped? { + try self.lock.withLock { + if let value = self.underlying { + return value } + let value = try body() + self.underlying = value + return value } } } extension ThreadSafeBox where Value == Int { + /// Atomically increments the stored integer value by 1. + /// + /// This method is only available when the wrapped value type is Int. public func increment() { self.lock.withLock { - if let value = self.underlying { - self.underlying = value + 1 - } + self.underlying = self.underlying + 1 } } + /// Atomically decrements the stored integer value by 1. + /// + /// This method is only available when the wrapped value type is Int. public func decrement() { self.lock.withLock { - if let value = self.underlying { - self.underlying = value - 1 - } + self.underlying = self.underlying - 1 } } } extension ThreadSafeBox where Value == String { + /// Atomically appends a string to the stored string value. + /// + /// This method is only available when the wrapped value type is String. + /// + /// - Parameter value: The string to append to the current stored value. public func append(_ value: String) { self.mutate { existingValue in - if let existingValue { - return existingValue + value - } else { - return value - } + existingValue + value } } } diff --git a/Sources/Basics/Observability.swift b/Sources/Basics/Observability.swift index 404d42a85c4..9083b3898e7 100644 --- a/Sources/Basics/Observability.swift +++ b/Sources/Basics/Observability.swift @@ -151,7 +151,7 @@ public final class ObservabilityScope: DiagnosticsEmitterProtocol, Sendable, Cus } var errorsReported: Bool { - self._errorsReported.get() ?? false + self._errorsReported.get() } } } diff --git a/Sources/Build/BuildOperation.swift b/Sources/Build/BuildOperation.swift index ee3b48adfb6..dd1adee93bb 100644 --- a/Sources/Build/BuildOperation.swift +++ b/Sources/Build/BuildOperation.swift @@ -171,10 +171,10 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS } /// The build description resulting from planing. - private let buildDescription = ThreadSafeBox() + private let buildDescription = AsyncThrowingValueMemoizer() /// The loaded package graph. - private let packageGraph = ThreadSafeBox() + private let packageGraph = AsyncThrowingValueMemoizer() /// File system to operate on. private var fileSystem: Basics.FileSystem { diff --git a/Sources/Build/ClangSupport.swift b/Sources/Build/ClangSupport.swift index af5fd8581a5..fe4fc3bc687 100644 --- a/Sources/Build/ClangSupport.swift +++ b/Sources/Build/ClangSupport.swift @@ -24,7 +24,7 @@ public enum ClangSupport { let features: [Feature] } - private static var cachedFeatures = ThreadSafeBox() + private static var cachedFeatures = ThreadSafeBox() public static func supportsFeature(name: String, toolchain: PackageModel.Toolchain) throws -> Bool { let features = try cachedFeatures.memoize { diff --git a/Sources/Build/LLBuildProgressTracker.swift b/Sources/Build/LLBuildProgressTracker.swift index 63a6a6caace..ec5d85fb06d 100644 --- a/Sources/Build/LLBuildProgressTracker.swift +++ b/Sources/Build/LLBuildProgressTracker.swift @@ -96,7 +96,7 @@ public final class BuildExecutionContext { // MARK: - Private - private var indexStoreAPICache = ThreadSafeBox>() + private var indexStoreAPICache = ThreadSafeBox?>() /// Reference to the index store API. var indexStoreAPI: Result { diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift index 5c0a2cb9412..cb8d0497c00 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -1223,7 +1223,7 @@ final class ParallelTestRunner { } self.finishedTests.enqueue(TestResult( unitTest: test, - output: output.get() ?? "", + output: output.get(), success: result != .failure, duration: duration )) diff --git a/Sources/DriverSupport/DriverSupportUtils.swift b/Sources/DriverSupport/DriverSupportUtils.swift index 1497ec1b94a..1f7ccfadd19 100644 --- a/Sources/DriverSupport/DriverSupportUtils.swift +++ b/Sources/DriverSupport/DriverSupportUtils.swift @@ -19,7 +19,7 @@ import class TSCBasic.Process import struct TSCBasic.ProcessResult public enum DriverSupport { - private static var flagsMap = ThreadSafeBox<[String: Set]>() + private static var flagsMap = ThreadSafeBox<[String: Set]>([:]) /// This checks _frontend_ supported flags, which are not necessarily supported in the driver. public static func checkSupportedFrontendFlags( @@ -29,8 +29,9 @@ public enum DriverSupport { ) -> Bool { let trimmedFlagSet = Set(flags.map { $0.trimmingCharacters(in: ["-"]) }) let swiftcPathString = toolchain.swiftCompilerPath.pathString + let entry = flagsMap.get() - if let entry = flagsMap.get(), let cachedSupportedFlagSet = entry[swiftcPathString + "-frontend"] { + if let cachedSupportedFlagSet = entry[swiftcPathString + "-frontend"] { return cachedSupportedFlagSet.intersection(trimmedFlagSet) == trimmedFlagSet } do { @@ -63,8 +64,9 @@ public enum DriverSupport { ) -> Bool { let trimmedFlagSet = Set(flags.map { $0.trimmingCharacters(in: ["-"]) }) let swiftcPathString = toolchain.swiftCompilerPath.pathString + let entry = flagsMap.get() - if let entry = flagsMap.get(), let cachedSupportedFlagSet = entry[swiftcPathString + "-driver"] { + if let cachedSupportedFlagSet = entry[swiftcPathString + "-driver"] { return cachedSupportedFlagSet.intersection(trimmedFlagSet) == trimmedFlagSet } do { diff --git a/Sources/PackageCollections/Storage/SQLitePackageCollectionsStorage.swift b/Sources/PackageCollections/Storage/SQLitePackageCollectionsStorage.swift index 295a496deb7..f499a831858 100644 --- a/Sources/PackageCollections/Storage/SQLitePackageCollectionsStorage.swift +++ b/Sources/PackageCollections/Storage/SQLitePackageCollectionsStorage.swift @@ -50,7 +50,7 @@ final class SQLitePackageCollectionsStorage: PackageCollectionsStorage, Closable private let ftsLock = NSLock() // FTS not supported on some platforms; the code falls back to "slow path" in that case // marked internal for testing - internal let useSearchIndices = ThreadSafeBox() + internal let useSearchIndices = ThreadSafeBox(false) // Targets have in-memory trie in addition to SQLite FTS as optimization private let targetTrie = Trie() @@ -725,9 +725,10 @@ final class SQLitePackageCollectionsStorage: PackageCollectionsStorage, Closable private func shouldUseSearchIndices() throws -> Bool { // Make sure createSchemaIfNecessary is called and useSearchIndices is set before reading it try self.withDB { _ in - self.useSearchIndices.get() ?? false + self.useSearchIndices.get() } } + internal func populateTargetTrie() async throws { try await withCheckedThrowingContinuation { continuation in self.populateTargetTrie(callback: { diff --git a/Sources/PackageModel/EnabledTrait.swift b/Sources/PackageModel/EnabledTrait.swift index 103c1042a6c..ae798b5b273 100644 --- a/Sources/PackageModel/EnabledTrait.swift +++ b/Sources/PackageModel/EnabledTrait.swift @@ -103,12 +103,10 @@ public struct EnabledTraitsMap { } public subscript(key: PackageIdentity) -> EnabledTraits { - get { storage.get()?.traits[key] ?? ["default"] } + get { storage.get().traits[key] ?? ["default"] } set { - storage.mutate { state -> Storage? in - guard var state = state else { - return Storage() - } + storage.mutate { (state: Storage) -> Storage in + var state = state // Omit adding "default" explicitly, since the map returns "default" // if there are no explicit traits enabled. This will allow us to check @@ -157,7 +155,7 @@ public struct EnabledTraitsMap { /// - Parameter key: The package identity to query. /// - Returns: The set of setters that disabled default traits, or `nil` if no disablers exist. public subscript(disablersFor key: PackageIdentity) -> Set? { - storage.get()?._disablers[key] + storage.get()._disablers[key] } /// Returns the set of setters that explicitly disabled default traits for a package identified by a string. @@ -178,7 +176,7 @@ public struct EnabledTraitsMap { /// - Parameter key: The package identity to query. /// - Returns: The set of setters that requested default traits, or `nil` if no default setters exist. public subscript(defaultSettersFor key: PackageIdentity) -> Set? { - storage.get()?._defaultSetters[key] + storage.get()._defaultSetters[key] } /// Returns the set of setters that requested default traits for a package identified by a string. @@ -196,7 +194,7 @@ public struct EnabledTraitsMap { /// - Parameter key: The package identity to query. /// - Returns: The explicitly enabled traits, or `nil` if no traits were explicitly set (meaning the package uses defaults). public subscript(explicitlyEnabledTraitsFor key: PackageIdentity) -> EnabledTraits? { - storage.get()?.traits[key] + storage.get().traits[key] } /// Returns a list of traits that were explicitly enabled for a given package. @@ -211,7 +209,7 @@ public struct EnabledTraitsMap { /// Returns a dictionary literal representation of the enabled traits map. public var dictionaryLiteral: [PackageIdentity: EnabledTraits] { - return storage.get()?.traits ?? [:] + return storage.get().traits } } diff --git a/Sources/PackageModel/UserToolchain.swift b/Sources/PackageModel/UserToolchain.swift index 36c27c03389..4bb37aa6965 100644 --- a/Sources/PackageModel/UserToolchain.swift +++ b/Sources/PackageModel/UserToolchain.swift @@ -43,7 +43,7 @@ public final class UserToolchain: Toolchain { public let librarySearchPaths: [AbsolutePath] /// Thread-safe cached runtime library paths - private let _runtimeLibraryPaths = ThreadSafeBox<[AbsolutePath]>() + private let _runtimeLibraryPaths = ThreadSafeBox<[AbsolutePath]?>() /// An array of paths to use with binaries produced by this toolchain at run time. public var runtimeLibraryPaths: [AbsolutePath] { @@ -94,7 +94,7 @@ public final class UserToolchain: Toolchain { private let _cachedTargetInfo = ThreadSafeBox() private var targetInfo: JSON? { - return _cachedTargetInfo.memoize { + return _cachedTargetInfo.memoizeOptional { // Only call out to the swift compiler to fetch the target info when necessary return try? _targetInfo ?? Self.getTargetInfo(swiftCompiler: swiftCompilerPath) } @@ -105,7 +105,7 @@ public final class UserToolchain: Toolchain { // A version string that can be used to identify the swift compiler version public var swiftCompilerVersion: String? { - return _swiftCompilerVersion.memoize { + return _swiftCompilerVersion.memoizeOptional { guard let targetInfo = self.targetInfo else { return nil } diff --git a/Sources/SPMBuildCore/Plugins/DefaultPluginScriptRunner.swift b/Sources/SPMBuildCore/Plugins/DefaultPluginScriptRunner.swift index a5b450a6f5a..f20e7ed822a 100644 --- a/Sources/SPMBuildCore/Plugins/DefaultPluginScriptRunner.swift +++ b/Sources/SPMBuildCore/Plugins/DefaultPluginScriptRunner.swift @@ -37,7 +37,7 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner, Cancellable { private let cancellator: Cancellator private let verboseOutput: Bool - private let sdkRootCache = ThreadSafeBox() + private let sdkRootCache = ThreadSafeBox() public init( fileSystem: Basics.FileSystem, @@ -402,26 +402,23 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner, Cancellable { /// Returns path to the sdk, if possible. // FIXME: This is copied from ManifestLoader. This should be consolidated when ManifestLoader is cleaned up. private func sdkRoot() -> Basics.AbsolutePath? { - if let sdkRoot = self.sdkRootCache.get() { - return sdkRoot - } - - var sdkRootPath: Basics.AbsolutePath? - // Find SDKROOT on macOS using xcrun. - #if os(macOS) - let foundPath = try? AsyncProcess.checkNonZeroExit( - args: "/usr/bin/xcrun", "--sdk", "macosx", "--show-sdk-path" - ) - guard let sdkRoot = foundPath?.spm_chomp(), !sdkRoot.isEmpty else { - return nil - } - if let path = try? Basics.AbsolutePath(validating: sdkRoot) { - sdkRootPath = path - self.sdkRootCache.put(path) - } - #endif + return self.sdkRootCache.memoizeOptional(body: { + var sdkRootPath: Basics.AbsolutePath? + // Find SDKROOT on macOS using xcrun. + #if os(macOS) + let foundPath = try? AsyncProcess.checkNonZeroExit( + args: "/usr/bin/xcrun", "--sdk", "macosx", "--show-sdk-path" + ) + guard let sdkRoot = foundPath?.spm_chomp(), !sdkRoot.isEmpty else { + return nil + } + if let path = try? Basics.AbsolutePath(validating: sdkRoot) { + sdkRootPath = path + } + #endif - return sdkRootPath + return sdkRootPath + }) } /// Private function that invokes a compiled plugin executable and communicates with it until it finishes. diff --git a/Sources/SourceControl/GitRepository.swift b/Sources/SourceControl/GitRepository.swift index 6dbdf063275..33674c623fd 100644 --- a/Sources/SourceControl/GitRepository.swift +++ b/Sources/SourceControl/GitRepository.swift @@ -410,10 +410,10 @@ public final class GitRepository: Repository, WorkingCheckout { private var cachedHashes = ThreadSafeKeyValueStore() private var cachedBlobs = ThreadSafeKeyValueStore() private var cachedTrees = ThreadSafeKeyValueStore() - private var cachedTags = ThreadSafeBox<[String]>() - private var cachedBranches = ThreadSafeBox<[String]>() - private var cachedIsBareRepo = ThreadSafeBox() - private var cachedHasSubmodules = ThreadSafeBox() + private var cachedTags = ThreadSafeBox<[String]?>() + private var cachedBranches = ThreadSafeBox<[String]?>() + private var cachedIsBareRepo = ThreadSafeBox() + private var cachedHasSubmodules = ThreadSafeBox() public convenience init(path: AbsolutePath, isWorkingRepo: Bool = true, cancellator: Cancellator? = .none) { // used in one-off operations on git repo, as such the terminator is not ver important diff --git a/Sources/SourceControl/RepositoryManager.swift b/Sources/SourceControl/RepositoryManager.swift index 248a2be308a..104f89a64c6 100644 --- a/Sources/SourceControl/RepositoryManager.swift +++ b/Sources/SourceControl/RepositoryManager.swift @@ -343,12 +343,12 @@ public class RepositoryManager: Cancellable { // If we are offline and have a valid cached repository, use the cache anyway. if try isOffline(error) && self.provider.isValidDirectory(cachedRepositoryPath, for: handle.repository) { // For the first offline use in the lifetime of this repository manager, emit a warning. - var warningState = self.emitNoConnectivityWarning.get(default: [:]) - if !(warningState[handle.repository.url] ?? false) { - warningState[handle.repository.url] = true - self.emitNoConnectivityWarning.put(warningState) - observabilityScope.emit(warning: "no connectivity to \(handle.repository.url), using previously cached repository state") - } + self.emitNoConnectivityWarning.mutate(body: { + if !$0[handle.repository.url, default: false] { + $0[handle.repository.url] = true + observabilityScope.emit(warning: "no connectivity to \(handle.repository.url), using previously cached repository state") + } + }) observabilityScope.emit(info: "using previously cached repository state for \(package)") cacheUsed = true diff --git a/Sources/Workspace/PackageContainer/FileSystemPackageContainer.swift b/Sources/Workspace/PackageContainer/FileSystemPackageContainer.swift index 2517c3d7f9c..4bbf8333ddc 100644 --- a/Sources/Workspace/PackageContainer/FileSystemPackageContainer.swift +++ b/Sources/Workspace/PackageContainer/FileSystemPackageContainer.swift @@ -39,7 +39,7 @@ public struct FileSystemPackageContainer: PackageContainer { private let observabilityScope: ObservabilityScope /// cached version of the manifest - private let manifest = ThreadSafeBox() + private let manifest = AsyncThrowingValueMemoizer() public init( package: PackageReference, @@ -68,7 +68,7 @@ public struct FileSystemPackageContainer: PackageContainer { } private func loadManifest() async throws -> Manifest { - try await manifest.memoize() { + try await manifest.memoize { let packagePath: AbsolutePath switch self.package.kind { case .root(let path), .fileSystem(let path): diff --git a/Sources/Workspace/PackageContainer/RegistryPackageContainer.swift b/Sources/Workspace/PackageContainer/RegistryPackageContainer.swift index 1279e0d7074..7b42c5c6907 100644 --- a/Sources/Workspace/PackageContainer/RegistryPackageContainer.swift +++ b/Sources/Workspace/PackageContainer/RegistryPackageContainer.swift @@ -31,7 +31,7 @@ public class RegistryPackageContainer: PackageContainer { private let currentToolsVersion: ToolsVersion private let observabilityScope: ObservabilityScope - private var knownVersionsCache = ThreadSafeBox<[Version]>() + private var knownVersionsCache = AsyncThrowingValueMemoizer<[Version]>() private var toolsVersionsCache = ThrowingAsyncKeyValueMemoizer() private var validToolsVersionsCache = AsyncKeyValueMemoizer() private var manifestsCache = ThrowingAsyncKeyValueMemoizer() diff --git a/Sources/Workspace/PackageContainer/SourceControlPackageContainer.swift b/Sources/Workspace/PackageContainer/SourceControlPackageContainer.swift index 89d05743699..70b8affa0a9 100644 --- a/Sources/Workspace/PackageContainer/SourceControlPackageContainer.swift +++ b/Sources/Workspace/PackageContainer/SourceControlPackageContainer.swift @@ -69,7 +69,7 @@ internal final class SourceControlPackageContainer: PackageContainer, CustomStri private var dependenciesCache = [String: [ProductFilter: (Manifest, [Constraint])]]() private var dependenciesCacheLock = NSLock() - private var knownVersionsCache = ThreadSafeBox<[Version: String]>() + private var knownVersionsCache = ThreadSafeBox<[Version: String]?>() private var manifestsCache = ThrowingAsyncKeyValueMemoizer() private var toolsVersionsCache = ThreadSafeKeyValueStore() diff --git a/Tests/BasicsTests/AsyncProcessTests.swift b/Tests/BasicsTests/AsyncProcessTests.swift index 0bc3b94cb32..d8fee50b004 100644 --- a/Tests/BasicsTests/AsyncProcessTests.swift +++ b/Tests/BasicsTests/AsyncProcessTests.swift @@ -77,7 +77,7 @@ final class AsyncProcessTests: XCTestCase { let args = ["whoami"] let answer = NSUserName() #endif - let popenResult = ThreadSafeBox>() + let popenResult = ThreadSafeBox?>() let group = DispatchGroup() group.enter() AsyncProcess.popen(arguments: args) { result in @@ -246,7 +246,7 @@ final class AsyncProcessTests: XCTestCase { let stdout = ThreadSafeBox<[UInt8]>([]) let process = AsyncProcess(scriptName: "in-to-out\(ProcessInfo.batSuffix)", outputRedirection: .stream(stdout: { stdoutBytes in stdout.mutate { - $0?.append(contentsOf: stdoutBytes) + $0.append(contentsOf: stdoutBytes) } }, stderr: { _ in })) let stdinStream = try process.launch() @@ -258,7 +258,7 @@ final class AsyncProcessTests: XCTestCase { try process.waitUntilExit() - XCTAssertEqual(String(decoding: stdout.get(default: []), as: UTF8.self), "hello\(ProcessInfo.EOL)") + XCTAssertEqual(String(decoding: stdout.get(), as: UTF8.self), "hello\(ProcessInfo.EOL)") } func testStdoutStdErr() throws { @@ -359,19 +359,19 @@ final class AsyncProcessTests: XCTestCase { let stderr = ThreadSafeBox<[UInt8]>([]) let process = AsyncProcess(scriptName: "long-stdout-stderr\(ProcessInfo.batSuffix)", outputRedirection: .stream(stdout: { stdoutBytes in stdout.mutate { - $0?.append(contentsOf: stdoutBytes) + $0.append(contentsOf: stdoutBytes) } }, stderr: { stderrBytes in stderr.mutate { - $0?.append(contentsOf: stderrBytes) + $0.append(contentsOf: stderrBytes) } })) try process.launch() try process.waitUntilExit() let count = 16 * 1024 - XCTAssertEqual(String(bytes: stdout.get(default: []), encoding: .utf8), String(repeating: "1", count: count)) - XCTAssertEqual(String(bytes: stderr.get(default: []), encoding: .utf8), String(repeating: "2", count: count)) + XCTAssertEqual(String(bytes: stdout.get(), encoding: .utf8), String(repeating: "1", count: count)) + XCTAssertEqual(String(bytes: stderr.get(), encoding: .utf8), String(repeating: "2", count: count)) } func testStdoutStdErrStreamingRedirected() throws { @@ -380,11 +380,11 @@ final class AsyncProcessTests: XCTestCase { let process = AsyncProcess(scriptName: "long-stdout-stderr\(ProcessInfo.batSuffix)", outputRedirection: .stream(stdout: { stdoutBytes in stdout.mutate { - $0?.append(contentsOf: stdoutBytes) + $0.append(contentsOf: stdoutBytes) } }, stderr: { stderrBytes in stderr.mutate { - $0?.append(contentsOf: stderrBytes) + $0.append(contentsOf: stderrBytes) } }, redirectStderr: true)) try process.launch() @@ -398,8 +398,8 @@ final class AsyncProcessTests: XCTestCase { let expectedStdout = String(repeating: "12", count: count) let expectedStderr = "" #endif - XCTAssertEqual(String(bytes: stdout.get(default: []), encoding: .utf8), expectedStdout) - XCTAssertEqual(String(bytes: stderr.get(default: []), encoding: .utf8), expectedStderr) + XCTAssertEqual(String(bytes: stdout.get(), encoding: .utf8), expectedStdout) + XCTAssertEqual(String(bytes: stderr.get(), encoding: .utf8), expectedStderr) } func testWorkingDirectory() throws { diff --git a/Tests/BasicsTests/ConcurrencyHelpersTests.swift b/Tests/BasicsTests/ConcurrencyHelpersTests.swift index 7350a7e06c7..5104d02fabc 100644 --- a/Tests/BasicsTests/ConcurrencyHelpersTests.swift +++ b/Tests/BasicsTests/ConcurrencyHelpersTests.swift @@ -85,45 +85,6 @@ struct ConcurrencyHelpersTest { } } - @Test( - .bug("https://github.com/swiftlang/swift-package-manager/issues/8770"), - ) - func threadSafeBox() async throws { - // Actor to serialize the critical section that was previously handled by the serial queue - actor SerialCoordinator { - func processTask(_ index: Int, winner: inout Int?, cache: ThreadSafeBox) { - // This simulates the serial queue behavior - both winner determination - // and cache memoization happen atomically in the same serial context - if winner == nil { - winner = index - } - cache.memoize { - index - } - } - } - - for num in 0 ..< 100 { - var winner: Int? - let cache = ThreadSafeBox() - let coordinator = SerialCoordinator() - - try await withThrowingTaskGroup(of: Void.self) { group in - for index in 0 ..< 1000 { - group.addTask { - // Random sleep to simulate concurrent access timing - try await Task.sleep(nanoseconds: UInt64(Double.random(in: 100 ... 300) * 1000)) - - // Process both winner determination and cache memoization serially - await coordinator.processTask(index, winner: &winner, cache: cache) - } - } - try await group.waitForAll() - } - #expect(cache.get() == winner, "Iteration \(num) failed") - } - } - @Suite struct AsyncOperationQueueTests { fileprivate actor ResultsTracker { @@ -278,3 +239,930 @@ struct ConcurrencyHelpersTest { } } } + +extension ConcurrencyHelpersTest { + @Suite struct ThreadSafeBoxTests { + // MARK: - Basic Functionality Tests + + @Test + func basicGetAndPut() { + let box = ThreadSafeBox(42) + #expect(box.get() == 42) + + box.put(100) + #expect(box.get() == 100) + } + + @Test + func mutateReturningNewValue() { + let box = ThreadSafeBox(10) + box.mutate { value in + value * 2 + } + #expect(box.get() == 20) + } + + @Test + func mutateInPlace() { + let box = ThreadSafeBox([1, 2, 3]) + box.mutate { value in + value.append(4) + } + #expect(box.get() == [1, 2, 3, 4]) + } + + // MARK: - Optional Value Tests + + @Test + func optionalInitEmpty() { + let box = ThreadSafeBox() + #expect(box.get() == nil) + } + + @Test + func optionalClear() { + let box = ThreadSafeBox(42) + #expect(box.get() == 42) + + box.clear() + #expect(box.get() == nil) + } + + @Test + func optionalGetWithDefault() { + let emptyBox = ThreadSafeBox() + #expect(emptyBox.get(default: 999) == 999) + + let filledBox = ThreadSafeBox(42) + #expect(filledBox.get(default: 999) == 42) + } + + @Test + func memoizeComputesOnce() { + let box = ThreadSafeBox() + var computeCount = 0 + + let result1 = box.memoize { + computeCount += 1 + return 42 + } + #expect(result1 == 42) + #expect(computeCount == 1) + + let result2 = box.memoize { + computeCount += 1 + return 99 + } + #expect(result2 == 42) + #expect(computeCount == 1) + } + + @Test + func memoizeOptionalNilValue() { + let box = ThreadSafeBox() + var computeCount = 0 + + let result1 = box.memoizeOptional { + computeCount += 1 + return nil + } + #expect(result1 == nil) + #expect(computeCount == 1) + + // Should recompute since result was nil + let result2 = box.memoizeOptional { + computeCount += 1 + return 42 + } + #expect(result2 == 42) + #expect(computeCount == 2) + + // Now should use cached value + let result3 = box.memoizeOptional { + computeCount += 1 + return 99 + } + #expect(result3 == 42) + #expect(computeCount == 2) + } + + // MARK: - Int Extension Tests + + @Test + func intIncrement() { + let box = ThreadSafeBox(0) + box.increment() + #expect(box.get() == 1) + box.increment() + #expect(box.get() == 2) + } + + @Test + func intDecrement() { + let box = ThreadSafeBox(10) + box.decrement() + #expect(box.get() == 9) + box.decrement() + #expect(box.get() == 8) + } + + // MARK: - String Extension Tests + + @Test + func stringAppend() { + let box = ThreadSafeBox("Hello") + box.append(" World") + #expect(box.get() == "Hello World") + box.append("!") + #expect(box.get() == "Hello World!") + } + + // MARK: - Dynamic Member Lookup Tests + + @Test + func dynamicMemberReadOnly() { + struct Person { + let name: String + let age: Int + } + + let box = ThreadSafeBox(Person(name: "Alice", age: 30)) + #expect(box.name == "Alice") + #expect(box.age == 30) + } + + @Test + func dynamicMemberWritable() { + struct Counter { + var count: Int + var label: String + } + + let box = ThreadSafeBox(Counter(count: 0, label: "Test")) + #expect(box.count == 0) + #expect(box.label == "Test") + + box.count = 42 + #expect(box.count == 42) + #expect(box.label == "Test") + + box.label = "Updated" + #expect(box.count == 42) + #expect(box.label == "Updated") + } + + // MARK: - Thread Safety Tests + + @Test( + .bug("https://github.com/swiftlang/swift-package-manager/issues/8770"), + ) + func concurrentMemoization() async throws { + actor SerialCoordinator { + func processTask(_ index: Int, winner: inout Int?, cache: ThreadSafeBox) { + if winner == nil { + winner = index + } + cache.memoize { + index + } + } + } + + for num in 0 ..< 100 { + var winner: Int? + let cache = ThreadSafeBox() + let coordinator = SerialCoordinator() + + try await withThrowingTaskGroup(of: Void.self) { group in + for index in 0 ..< 1000 { + group.addTask { + try await Task.sleep(nanoseconds: UInt64(Double.random(in: 100 ... 300) * 1000)) + await coordinator.processTask(index, winner: &winner, cache: cache) + } + } + try await group.waitForAll() + } + #expect(cache.get() == winner, "Iteration \(num) failed") + } + } + + @Test + func concurrentIncrements() async throws { + let box = ThreadSafeBox(0) + let iterations = 1000 + + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..= 0 && finalValue < 1000) + } + } + + @Suite struct AsyncThrowingValueMemoizerTests { + // MARK: - Basic Functionality Tests + + @Test + func memoizeComputesOnlyOnce() async throws { + let memoizer = AsyncThrowingValueMemoizer() + nonisolated(unsafe) var computeCount = 0 + + let result1 = try await memoizer.memoize { + computeCount += 1 + return 42 + } + #expect(result1 == 42) + #expect(computeCount == 1) + + let result2 = try await memoizer.memoize { + computeCount += 1 + return 99 + } + #expect(result2 == 42) + #expect(computeCount == 1) + } + + @Test + func memoizeWithAsyncWork() async throws { + let memoizer = AsyncThrowingValueMemoizer() + + let result = try await memoizer.memoize { + try await Task.sleep(nanoseconds: 1_000_000) + return "computed" + } + + #expect(result == "computed") + } + + @Test + func memoizeCachesError() async throws { + struct TestError: Error, Equatable {} + let memoizer = AsyncThrowingValueMemoizer() + + await #expect(throws: TestError.self) { + try await memoizer.memoize { + throw TestError() + } + } + + // After error, subsequent calls should return the cached error + await #expect(throws: TestError.self) { + try await memoizer.memoize { + 100 + } + } + } + + // MARK: - Concurrency Tests + + @Test + func concurrentMemoizationSharesWork() async throws { + let memoizer = AsyncThrowingValueMemoizer() + nonisolated(unsafe) var computeCount = 0 + let lock = NSLock() + + try await withThrowingTaskGroup(of: Int.self) { group in + for _ in 0..<100 { + group.addTask { + try await memoizer.memoize { + lock.withLock { + computeCount += 1 + } + try await Task.sleep(nanoseconds: 10_000_000) + return 42 + } + } + } + + var results = [Int]() + for try await result in group { + results.append(result) + } + + #expect(results.count == 100) + #expect(results.allSatisfy { $0 == 42 }) + } + + // Should only compute once despite 100 concurrent calls + #expect(computeCount == 1) + } + + @Test + func concurrentMemoizationWithQuickCompletion() async throws { + let memoizer = AsyncThrowingValueMemoizer() + nonisolated(unsafe) var computeCount = 0 + let lock = NSLock() + + try await withThrowingTaskGroup(of: String.self) { group in + for i in 0..<50 { + group.addTask { + try await memoizer.memoize { + lock.withLock { + computeCount += 1 + } + return "value-\(i)" + } + } + } + + var results = [String]() + for try await result in group { + results.append(result) + } + + #expect(results.count == 50) + // All results should be the same (from the first caller) + #expect(Set(results).count == 1) + } + + #expect(computeCount == 1) + } + + @Test + func concurrentErrorPropagation() async throws { + struct TestError: Error {} + let memoizer = AsyncThrowingValueMemoizer() + var errorCount = 0 + let lock = NSLock() + + await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<20 { + group.addTask { + do { + _ = try await memoizer.memoize { + try await Task.sleep(nanoseconds: 5_000_000) + throw TestError() + } + } catch { + lock.withLock { + errorCount += 1 + } + } + } + } + + // Consume all results (ignoring errors) + while let _ = try? await group.next() {} + } + + // All concurrent calls should receive the error + #expect(errorCount == 20) + } + + @Test + func sequentialMemoizationAfterSuccess() async throws { + let memoizer = AsyncThrowingValueMemoizer() + + let first = try await memoizer.memoize { + try await Task.sleep(nanoseconds: 1_000_000) + return 42 + } + #expect(first == 42) + + let second = try await memoizer.memoize { + try await Task.sleep(nanoseconds: 1_000_000) + return 99 + } + #expect(second == 42) + } + + @Test + func complexValueType() async throws { + struct ComplexValue: Sendable, Equatable { + let id: Int + let name: String + let tags: [String] + } + + let memoizer = AsyncThrowingValueMemoizer() + + let result = try await memoizer.memoize { + try await Task.sleep(nanoseconds: 1_000_000) + return ComplexValue(id: 1, name: "Test", tags: ["a", "b", "c"]) + } + + #expect(result.id == 1) + #expect(result.name == "Test") + #expect(result.tags == ["a", "b", "c"]) + } + + @Test + func memoizeWithVariableDelay() async throws { + let memoizer = AsyncThrowingValueMemoizer() + nonisolated(unsafe) var firstCallComplete = false + let lock = NSLock() + + try await withThrowingTaskGroup(of: Int.self) { group in + // First task with delay + group.addTask { + try await memoizer.memoize { + try await Task.sleep(nanoseconds: 20_000_000) + lock.withLock { + firstCallComplete = true + } + return 100 + } + } + + // Wait a bit then add more tasks + try await Task.sleep(nanoseconds: 5_000_000) + + for _ in 0..<10 { + group.addTask { + try await memoizer.memoize { + return 999 + } + } + } + + var results = [Int]() + for try await result in group { + results.append(result) + } + + #expect(results.count == 11) + #expect(results.allSatisfy { $0 == 100 }) + } + + let wasFirstCallComplete = lock.withLock { firstCallComplete } + #expect(wasFirstCallComplete == true) + } + } + + @Suite struct AsyncKeyValueMemoizerTests { + // MARK: - Basic Functionality Tests + + @Test + func memoizeComputesOncePerKey() async { + let memoizer = AsyncKeyValueMemoizer() + nonisolated(unsafe) var computeCount = 0 + + let result1 = await memoizer.memoize("key1") { + computeCount += 1 + return 42 + } + #expect(result1 == 42) + #expect(computeCount == 1) + + let result2 = await memoizer.memoize("key1") { + computeCount += 1 + return 99 + } + #expect(result2 == 42) + #expect(computeCount == 1) + + let result3 = await memoizer.memoize("key2") { + computeCount += 1 + return 100 + } + #expect(result3 == 100) + #expect(computeCount == 2) + } + + @Test + func memoizeWithAsyncWork() async { + let memoizer = AsyncKeyValueMemoizer() + + let result = await memoizer.memoize(1) { + await Task.yield() + return "computed" + } + + #expect(result == "computed") + } + + @Test + func memoizeMultipleKeys() async { + let memoizer = AsyncKeyValueMemoizer() + + let result1 = await memoizer.memoize(1) { "value1" } + let result2 = await memoizer.memoize(2) { "value2" } + let result3 = await memoizer.memoize(3) { "value3" } + + #expect(result1 == "value1") + #expect(result2 == "value2") + #expect(result3 == "value3") + + // Verify cached values + let cached1 = await memoizer.memoize(1) { "different" } + let cached2 = await memoizer.memoize(2) { "different" } + let cached3 = await memoizer.memoize(3) { "different" } + + #expect(cached1 == "value1") + #expect(cached2 == "value2") + #expect(cached3 == "value3") + } + + // MARK: - Concurrency Tests + + @Test + func concurrentMemoizationSharesWorkPerKey() async { + let memoizer = AsyncKeyValueMemoizer() + nonisolated(unsafe) var computeCount = 0 + let lock = NSLock() + + await withTaskGroup(of: Int.self) { group in + for _ in 0..<100 { + group.addTask { + await memoizer.memoize("shared-key") { + lock.withLock { + computeCount += 1 + } + try? await Task.sleep(nanoseconds: 10_000_000) + return 42 + } + } + } + + var results = [Int]() + for await result in group { + results.append(result) + } + + #expect(results.count == 100) + #expect(results.allSatisfy { $0 == 42 }) + } + + // Should only compute once despite 100 concurrent calls + #expect(computeCount == 1) + } + + @Test + func concurrentMemoizationDifferentKeys() async { + let memoizer = AsyncKeyValueMemoizer() + nonisolated(unsafe) var computeCount = 0 + let lock = NSLock() + + await withTaskGroup(of: String.self) { group in + for i in 0..<50 { + group.addTask { + await memoizer.memoize(i) { + lock.withLock { + computeCount += 1 + } + return "value-\(i)" + } + } + } + + var results = [String]() + for await result in group { + results.append(result) + } + + #expect(results.count == 50) + #expect(Set(results).count == 50) + } + + #expect(computeCount == 50) + } + + @Test + func complexKeyAndValueTypes() async { + struct Key: Hashable, Sendable { + let id: Int + let category: String + } + + struct Value: Sendable, Equatable { + let data: [String] + } + + let memoizer = AsyncKeyValueMemoizer() + + let key = Key(id: 1, category: "test") + let result = await memoizer.memoize(key) { + try? await Task.sleep(nanoseconds: 1_000_000) + return Value(data: ["a", "b", "c"]) + } + + #expect(result.data == ["a", "b", "c"]) + + let cached = await memoizer.memoize(key) { + Value(data: ["different"]) + } + #expect(cached.data == ["a", "b", "c"]) + } + } + + @Suite struct ThrowingAsyncKeyValueMemoizerTests { + // MARK: - Basic Functionality Tests + + @Test + func memoizeComputesOncePerKey() async throws { + let memoizer = ThrowingAsyncKeyValueMemoizer() + nonisolated(unsafe) var computeCount = 0 + + let result1 = try await memoizer.memoize("key1") { + computeCount += 1 + return 42 + } + #expect(result1 == 42) + #expect(computeCount == 1) + + let result2 = try await memoizer.memoize("key1") { + computeCount += 1 + return 99 + } + #expect(result2 == 42) + #expect(computeCount == 1) + + let result3 = try await memoizer.memoize("key2") { + computeCount += 1 + return 100 + } + #expect(result3 == 100) + #expect(computeCount == 2) + } + + @Test + func memoizeWithAsyncWork() async throws { + let memoizer = ThrowingAsyncKeyValueMemoizer() + + let result = try await memoizer.memoize(1) { + try await Task.sleep(nanoseconds: 1_000_000) + return "computed" + } + + #expect(result == "computed") + } + + @Test + func memoizeCachesErrorPerKey() async throws { + struct TestError: Error, Equatable {} + let memoizer = ThrowingAsyncKeyValueMemoizer() + + await #expect(throws: TestError.self) { + try await memoizer.memoize("error-key") { + throw TestError() + } + } + + // Subsequent calls to same key should return cached error + await #expect(throws: TestError.self) { + try await memoizer.memoize("error-key") { + 100 + } + } + + // Different key should work fine + let result = try await memoizer.memoize("success-key") { + 42 + } + #expect(result == 42) + } + + @Test + func memoizeMultipleKeysWithMixedResults() async throws { + struct TestError: Error {} + let memoizer = ThrowingAsyncKeyValueMemoizer() + + let result1 = try await memoizer.memoize(1) { "value1" } + #expect(result1 == "value1") + + await #expect(throws: TestError.self) { + try await memoizer.memoize(2) { + throw TestError() + } + } + + let result3 = try await memoizer.memoize(3) { "value3" } + #expect(result3 == "value3") + + // Verify cached values + let cached1 = try await memoizer.memoize(1) { "different" } + #expect(cached1 == "value1") + + await #expect(throws: TestError.self) { + try await memoizer.memoize(2) { "different" } + } + + let cached3 = try await memoizer.memoize(3) { "different" } + #expect(cached3 == "value3") + } + + // MARK: - Concurrency Tests + + @Test + func concurrentMemoizationSharesWorkPerKey() async throws { + let memoizer = ThrowingAsyncKeyValueMemoizer() + nonisolated(unsafe) var computeCount = 0 + let lock = NSLock() + + try await withThrowingTaskGroup(of: Int.self) { group in + for _ in 0..<100 { + group.addTask { + try await memoizer.memoize("shared-key") { + lock.withLock { + computeCount += 1 + } + try await Task.sleep(nanoseconds: 10_000_000) + return 42 + } + } + } + + var results = [Int]() + for try await result in group { + results.append(result) + } + + #expect(results.count == 100) + #expect(results.allSatisfy { $0 == 42 }) + } + + // Should only compute once despite 100 concurrent calls + #expect(computeCount == 1) + } + + @Test + func concurrentErrorPropagationPerKey() async throws { + struct TestError: Error {} + let memoizer = ThrowingAsyncKeyValueMemoizer() + var errorCount = 0 + let lock = NSLock() + + await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<20 { + group.addTask { + do { + _ = try await memoizer.memoize("error-key") { + try await Task.sleep(nanoseconds: 5_000_000) + throw TestError() + } + } catch { + lock.withLock { + errorCount += 1 + } + } + } + } + + // Consume all results (ignoring errors) + while let _ = try? await group.next() {} + } + + // All concurrent calls should receive the error + #expect(errorCount == 20) + } + + @Test + func concurrentMemoizationDifferentKeys() async throws { + let memoizer = ThrowingAsyncKeyValueMemoizer() + nonisolated(unsafe) var computeCount = 0 + let lock = NSLock() + + try await withThrowingTaskGroup(of: String.self) { group in + for i in 0..<50 { + group.addTask { + try await memoizer.memoize(i) { + lock.withLock { + computeCount += 1 + } + return "value-\(i)" + } + } + } + + var results = [String]() + for try await result in group { + results.append(result) + } + + #expect(results.count == 50) + #expect(Set(results).count == 50) + } + + #expect(computeCount == 50) + } + + @Test + func complexKeyAndValueTypes() async throws { + struct Key: Hashable, Sendable { + let id: Int + let category: String + } + + struct Value: Sendable, Equatable { + let data: [String] + } + + let memoizer = ThrowingAsyncKeyValueMemoizer() + + let key = Key(id: 1, category: "test") + let result = try await memoizer.memoize(key) { + try await Task.sleep(nanoseconds: 1_000_000) + return Value(data: ["a", "b", "c"]) + } + + #expect(result.data == ["a", "b", "c"]) + + let cached = try await memoizer.memoize(key) { + Value(data: ["different"]) + } + #expect(cached.data == ["a", "b", "c"]) + } + + @Test + func memoizeWithVariableDelayMultipleKeys() async throws { + let memoizer = ThrowingAsyncKeyValueMemoizer() + nonisolated(unsafe) var firstCallComplete = false + let lock = NSLock() + + try await withThrowingTaskGroup(of: (String, Int).self) { group in + // First task with delay for key1 + group.addTask { + let result = try await memoizer.memoize("key1") { + try await Task.sleep(nanoseconds: 20_000_000) + lock.withLock { + firstCallComplete = true + } + return 100 + } + return ("key1", result) + } + + // Wait a bit then add more tasks for both keys + try await Task.sleep(nanoseconds: 5_000_000) + + for _ in 0..<10 { + group.addTask { + let result = try await memoizer.memoize("key1") { + return 999 + } + return ("key1", result) + } + } + + for _ in 0..<10 { + group.addTask { + let result = try await memoizer.memoize("key2") { + return 200 + } + return ("key2", result) + } + } + + var results: [String: [Int]] = [:] + for try await (key, value) in group { + results[key, default: []].append(value) + } + + #expect(results["key1"]?.count == 11) + #expect(results["key1"]?.allSatisfy { $0 == 100 } == true) + #expect(results["key2"]?.count == 10) + #expect(results["key2"]?.allSatisfy { $0 == 200 } == true) + } + + let wasFirstCallComplete = lock.withLock { firstCallComplete } + #expect(wasFirstCallComplete == true) + } + } +} diff --git a/Tests/BasicsTests/LegacyHTTPClientTests.swift b/Tests/BasicsTests/LegacyHTTPClientTests.swift index b074c6ec9c8..25996730e13 100644 --- a/Tests/BasicsTests/LegacyHTTPClientTests.swift +++ b/Tests/BasicsTests/LegacyHTTPClientTests.swift @@ -353,13 +353,13 @@ final class LegacyHTTPClientTests: XCTestCase { try XCTSkipOnWindows(because: "https://github.com/swiftlang/swift-package-manager/issues/8501") let count = ThreadSafeBox(0) - let lastCall = ThreadSafeBox() + let lastCall = ThreadSafeBox() let maxAttempts = 5 let errorCode = Int.random(in: 500 ..< 600) let delay = SendableTimeInterval.milliseconds(100) let brokenHandler: LegacyHTTPClient.Handler = { _, _, completion in - let expectedDelta = pow(2.0, Double(count.get(default: 0) - 1)) * delay.timeInterval()! + let expectedDelta = pow(2.0, Double(count.get() - 1)) * delay.timeInterval()! let delta = lastCall.get().flatMap { Date().timeIntervalSince($0) } ?? 0 XCTAssertEqual(delta, expectedDelta, accuracy: 0.1) diff --git a/Tests/PackageLoadingTests/PDLoadingTests.swift b/Tests/PackageLoadingTests/PDLoadingTests.swift index 0ceab38b4e0..9116e9472e8 100644 --- a/Tests/PackageLoadingTests/PDLoadingTests.swift +++ b/Tests/PackageLoadingTests/PDLoadingTests.swift @@ -18,7 +18,7 @@ import XCTest class PackageDescriptionLoadingTests: XCTestCase, ManifestLoaderDelegate { lazy var manifestLoader = ManifestLoader(toolchain: try! UserToolchain.default, delegate: self) - var parsedManifest = ThreadSafeBox() + var parsedManifest = ThreadSafeBox(.root) func willLoad(packageIdentity: PackageModel.PackageIdentity, packageLocation: String, manifestPath: AbsolutePath) { // noop diff --git a/Tests/WorkspaceTests/WorkspaceTests.swift b/Tests/WorkspaceTests/WorkspaceTests.swift index 73db78f47c6..118c765041e 100644 --- a/Tests/WorkspaceTests/WorkspaceTests.swift +++ b/Tests/WorkspaceTests/WorkspaceTests.swift @@ -8313,7 +8313,7 @@ final class WorkspaceTests: XCTestCase { guard case .download(let fileSystem, let destination) = request.kind else { throw StringError("invalid request \(request.kind)") } - acceptHeaders.mutate { $0?.append(request.headers.get("accept").first!) } + acceptHeaders.mutate { $0.append(request.headers.get("accept").first!) } let contents: [UInt8] switch request.url.lastPathComponent { @@ -8873,7 +8873,7 @@ final class WorkspaceTests: XCTestCase { } concurrentRequests.increment() - if concurrentRequests.get()! > maxConcurrentRequests { + if concurrentRequests.get() > maxConcurrentRequests { XCTFail("too many concurrent requests \(concurrentRequests), expected \(maxConcurrentRequests)") }