diff --git a/Sources/ContainerizationExtras/AsyncLock.swift b/Sources/ContainerizationExtras/AsyncLock.swift index 702a3f2..2bdf026 100644 --- a/Sources/ContainerizationExtras/AsyncLock.swift +++ b/Sources/ContainerizationExtras/AsyncLock.swift @@ -16,21 +16,95 @@ import Foundation -/// `AsyncLock` provides a familiar locking API, with the main benefit being that it -/// is safe to call async methods while holding the lock. This is primarily used in spots -/// where an actor makes sense, but we may need to ensure we don't fall victim to actor -/// reentrancy issues. +/// An async-safe mutual exclusion lock for coordinating access to shared resources. +/// +/// `AsyncLock` provides a familiar locking API with the key benefit that it's safe to call +/// async methods while holding the lock. This addresses scenarios where traditional actors +/// might suffer from reentrancy issues or where you need explicit sequential access control. +/// +/// ## Use Cases +/// - Protecting shared mutable state that requires async operations +/// - Coordinating access to resources that don't support concurrent operations +/// - Avoiding actor reentrancy issues in complex async workflows +/// - Ensuring sequential execution of async operations +/// +/// ## Example usage: +/// ```swift +/// actor ResourceManager { +/// private let lock = AsyncLock() +/// private var resources: [String] = [] +/// +/// func addResource(_ name: String) async { +/// await lock.withLock { context in +/// // Async operations are safe within the lock +/// let processedName = await processResourceName(name) +/// resources.append(processedName) +/// await notifyObservers(about: processedName) +/// } +/// } +/// +/// func getResourceCount() async -> Int { +/// await lock.withLock { context in +/// return resources.count +/// } +/// } +/// } +/// ``` +/// +/// ## Threading Safety +/// This lock is designed for use within actors or other async contexts and provides +/// mutual exclusion without blocking threads. Operations are queued and resumed +/// sequentially as the lock becomes available. public actor AsyncLock { private var busy = false private var queue: ArraySlice> = [] + /// A context object provided to closures executed within the lock. + /// + /// The context serves as proof that the code is executing within the lock's + /// critical section. While currently empty, it may be extended in the future + /// to provide lock-specific functionality. public struct Context: Sendable { fileprivate init() {} } + /// Creates a new AsyncLock instance. + /// + /// The lock starts in an unlocked state and is ready for immediate use. public init() {} - /// withLock provides a scoped locking API to run a function while holding the lock. + /// Executes a closure while holding the lock, ensuring exclusive access. + /// + /// - Parameter body: An async closure to execute while holding the lock. + /// The closure receives a `Context` parameter as proof of lock ownership. + /// - Returns: The value returned by the closure + /// - Throws: Any error thrown by the closure + /// + /// This method provides scoped locking - the lock is automatically acquired before + /// the closure executes and released when the closure completes (either normally + /// or by throwing an error). + /// + /// If the lock is already held, the current operation will suspend until the lock + /// becomes available. Operations are queued and executed in FIFO order. + /// + /// ## Example: + /// ```swift + /// let lock = AsyncLock() + /// var counter = 0 + /// + /// // Safely increment counter with async work + /// let result = await lock.withLock { context in + /// let oldValue = counter + /// await Task.sleep(nanoseconds: 1_000_000) // Simulate async work + /// counter = oldValue + 1 + /// return counter + /// } + /// ``` + /// + /// ## Performance Notes + /// - The lock uses actor isolation, so there's no thread blocking + /// - Suspended operations consume minimal memory + /// - Lock contention is resolved in first-in-first-out order public func withLock(_ body: @Sendable @escaping (Context) async throws -> T) async rethrows -> T { while self.busy { await withCheckedContinuation { cc in diff --git a/Sources/ContainerizationExtras/CIDRAddress.swift b/Sources/ContainerizationExtras/CIDRAddress.swift index 55c9a6e..029b3b8 100644 --- a/Sources/ContainerizationExtras/CIDRAddress.swift +++ b/Sources/ContainerizationExtras/CIDRAddress.swift @@ -14,22 +14,56 @@ // limitations under the License. //===----------------------------------------------------------------------===// -/// Describes an IPv4 CIDR address block. +/// Represents an IPv4 CIDR (Classless Inter-Domain Routing) address block. +/// +/// A CIDR block defines a range of IP addresses using a base address and a prefix length. +/// This struct provides functionality for subnet calculations, address containment checks, +/// and network overlap detection. +/// +/// ## Example usage: +/// ```swift +/// // Create from CIDR notation +/// let cidr = try CIDRAddress("192.168.1.0/24") +/// print(cidr.lower) // 192.168.1.0 +/// print(cidr.upper) // 192.168.1.255 +/// +/// // Check if an address is in the block +/// let testAddr = try IPv4Address("192.168.1.100") +/// print(cidr.contains(ipv4: testAddr)) // true +/// +/// // Get address index within the block +/// if let index = cidr.getIndex(testAddr) { +/// print("Address index: \(index)") // 100 +/// } +/// ``` public struct CIDRAddress: CustomStringConvertible, Equatable, Sendable { - /// The base IPv4 address of the CIDR block. + /// The base (network) IPv4 address of the CIDR block. + /// This is the lowest address in the range with all host bits set to 0. public let lower: IPv4Address - /// The last IPv4 address of the CIDR block + /// The broadcast IPv4 address of the CIDR block. + /// This is the highest address in the range with all host bits set to 1. public let upper: IPv4Address - /// The IPv4 address component of the CIDR block. + /// The IPv4 address component used to create this CIDR block. + /// This may be any address within the block, not necessarily the network address. public let address: IPv4Address - /// The address prefix length for the CIDR block, which determines its size. + /// The prefix length (subnet mask) for the CIDR block, which determines its size. + /// Valid range is 0-32, where 32 represents a single host and 0 represents all IPv4 addresses. public let prefixLength: PrefixLength - /// Create an CIDR address block from its text representation. + /// Create a CIDR address block from its text representation. + /// + /// - Parameter cidr: A string in CIDR notation (e.g., "192.168.1.0/24") + /// - Throws: `NetworkAddressError.invalidCIDR` if the format is invalid + /// + /// ## Example: + /// ```swift + /// let cidr = try CIDRAddress("10.0.0.0/8") // 10.0.0.0 - 10.255.255.255 + /// let host = try CIDRAddress("192.168.1.1/32") // Single host + /// ``` public init(_ cidr: String) throws { let split = cidr.components(separatedBy: "/") guard split.count == 2 else { @@ -48,7 +82,20 @@ public struct CIDRAddress: CustomStringConvertible, Equatable, Sendable { upper = IPv4Address(fromValue: lower.value + prefixLength.suffixMask32) } - /// Create a CIDR address from a member IP and a prefix length. + /// Create a CIDR address block from an IP address and prefix length. + /// + /// - Parameters: + /// - address: Any IPv4 address within the desired network + /// - prefixLength: The subnet mask length (0-32) + /// - Throws: `NetworkAddressError.invalidCIDR` if the prefix length is invalid + /// + /// ## Example: + /// ```swift + /// let addr = try IPv4Address("192.168.1.150") + /// let cidr = try CIDRAddress(addr, prefixLength: 24) + /// print(cidr.description) // "192.168.1.150/24" + /// print(cidr.lower) // "192.168.1.0" + /// ``` public init(_ address: IPv4Address, prefixLength: PrefixLength) throws { guard prefixLength >= 0 && prefixLength <= 32 else { throw NetworkAddressError.invalidCIDR(cidr: "\(address)/\(prefixLength)") @@ -60,7 +107,23 @@ public struct CIDRAddress: CustomStringConvertible, Equatable, Sendable { upper = IPv4Address(fromValue: lower.value + prefixLength.suffixMask32) } - /// Create the smallest CIDR block that includes the lower and upper bounds. + /// Create the smallest CIDR block that encompasses the given address range. + /// + /// - Parameters: + /// - lower: The lowest IPv4 address that must be included + /// - upper: The highest IPv4 address that must be included + /// - Throws: `NetworkAddressError.invalidAddressRange` if lower > upper + /// + /// This initializer finds the minimal prefix length that creates a CIDR block + /// containing both the lower and upper addresses. + /// + /// ## Example: + /// ```swift + /// let start = try IPv4Address("192.168.1.100") + /// let end = try IPv4Address("192.168.1.200") + /// let cidr = try CIDRAddress(lower: start, upper: end) + /// // Results in a block that contains both addresses + /// ``` public init(lower: IPv4Address, upper: IPv4Address) throws { guard lower.value <= upper.value else { throw NetworkAddressError.invalidAddressRange(lower: lower.description, upper: upper.description) @@ -85,9 +148,25 @@ public struct CIDRAddress: CustomStringConvertible, Equatable, Sendable { self.upper = upper } - /// Get the offset of the specified address, relative to the - /// base address for the CIDR block, returning nil if the block - /// does not contain the address. + /// Get the zero-based index of the specified address within this CIDR block. + /// + /// - Parameter address: The IPv4 address to find the index for + /// - Returns: The index of the address within the block, or `nil` if not contained + /// + /// The index represents the offset from the network base address (lower bound). + /// This is useful for address allocation and iteration within a subnet. + /// + /// ## Example: + /// ```swift + /// let cidr = try CIDRAddress("192.168.1.0/24") + /// let addr = try IPv4Address("192.168.1.10") + /// if let index = cidr.getIndex(addr) { + /// print("Address index: \(index)") // 10 + /// } + /// + /// let outOfRange = try IPv4Address("192.168.2.1") + /// print(cidr.getIndex(outOfRange)) // nil + /// ``` public func getIndex(_ address: IPv4Address) -> UInt32? { guard address.value >= lower.value && address.value <= upper.value else { return nil @@ -96,35 +175,84 @@ public struct CIDRAddress: CustomStringConvertible, Equatable, Sendable { return address.value - lower.value } - /// Return true if the CIDR block contains the specified address. + /// Check if the CIDR block contains the specified IPv4 address. + /// + /// - Parameter ipv4: The IPv4 address to test for containment + /// - Returns: `true` if the address is within this CIDR block's range + /// + /// ## Example: + /// ```swift + /// let cidr = try CIDRAddress("10.0.0.0/8") + /// print(cidr.contains(ipv4: try IPv4Address("10.5.1.1"))) // true + /// print(cidr.contains(ipv4: try IPv4Address("192.168.1.1"))) // false + /// ``` public func contains(ipv4: IPv4Address) -> Bool { lower.value <= ipv4.value && ipv4.value <= upper.value } - /// Return true if the CIDR block contains all addresses of another CIDR block. + /// Check if this CIDR block completely contains another CIDR block. + /// + /// - Parameter cidr: The other CIDR block to test for containment + /// - Returns: `true` if the other block is entirely within this block + /// + /// ## Example: + /// ```swift + /// let large = try CIDRAddress("192.168.0.0/16") // /16 network + /// let small = try CIDRAddress("192.168.1.0/24") // /24 subnet + /// print(large.contains(cidr: small)) // true + /// print(small.contains(cidr: large)) // false + /// ``` public func contains(cidr: CIDRAddress) -> Bool { lower.value <= cidr.lower.value && cidr.upper.value <= upper.value } - /// Return true if the CIDR block shares any addresses with another CIDR block. + /// Check if this CIDR block shares any addresses with another CIDR block. + /// + /// - Parameter cidr: The other CIDR block to test for overlap + /// - Returns: `true` if the blocks have any addresses in common + /// + /// This method detects any form of overlap: partial overlap, complete containment, + /// or identical ranges. + /// + /// ## Example: + /// ```swift + /// let cidr1 = try CIDRAddress("192.168.1.0/24") + /// let cidr2 = try CIDRAddress("192.168.1.128/25") + /// let cidr3 = try CIDRAddress("192.168.2.0/24") + /// + /// print(cidr1.overlaps(cidr: cidr2)) // true (cidr2 is subset) + /// print(cidr1.overlaps(cidr: cidr3)) // false (different networks) + /// ``` public func overlaps(cidr: CIDRAddress) -> Bool { (lower.value <= cidr.lower.value && upper.value >= cidr.lower.value) || (upper.value >= cidr.upper.value && lower.value <= cidr.upper.value) } - /// Retrieve the text representation of the CIDR block. + /// Returns the text representation of the CIDR block in standard notation. + /// + /// The format is "address/prefix_length" where address is the original address + /// used to create the block (not necessarily the network address). public var description: String { "\(address)/\(prefixLength)" } } +// MARK: - Codable Conformance extension CIDRAddress: Codable { + /// Creates a CIDRAddress from a JSON string representation. + /// + /// - Parameter decoder: The decoder to read data from + /// - Throws: `DecodingError` if the string is not valid CIDR notation public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let text = try container.decode(String.self) try self.init(text) } + /// Encodes the CIDRAddress as a JSON string in CIDR notation. + /// + /// - Parameter encoder: The encoder to write data to + /// - Throws: `EncodingError` if encoding fails public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(self.description) diff --git a/Sources/ContainerizationExtras/IPAddress.swift b/Sources/ContainerizationExtras/IPAddress.swift index 850b4fe..e9ceb39 100644 --- a/Sources/ContainerizationExtras/IPAddress.swift +++ b/Sources/ContainerizationExtras/IPAddress.swift @@ -15,11 +15,42 @@ //===----------------------------------------------------------------------===// /// Facilitates conversion between IPv4 address representations. +/// +/// `IPv4Address` provides multiple ways to create and work with IPv4 addresses: +/// - From dotted-decimal strings (e.g., "192.168.1.1") +/// - From network byte arrays in big-endian order +/// - From 32-bit integer values +/// +/// The struct supports common networking operations like subnet prefix calculation +/// and provides seamless integration with JSON encoding/decoding. +/// +/// ## Example usage: +/// ```swift +/// // Create from different representations +/// let addr1 = try IPv4Address("192.168.1.1") +/// let addr2 = try IPv4Address(fromNetworkBytes: [192, 168, 1, 1]) +/// let addr3 = IPv4Address(fromValue: 0xc0a80101) +/// +/// // All three represent the same address +/// print(addr1 == addr2 && addr2 == addr3) // true +/// +/// // Get network prefix +/// let network = addr1.prefix(prefixLength: 24) // 192.168.1.0 +/// ``` public struct IPv4Address: Codable, CustomStringConvertible, Equatable, Sendable { - /// The address as a 32-bit integer. + /// The address as a 32-bit integer in host byte order. public let value: UInt32 - /// Create an address from a dotted-decimal string, such as "192.168.64.10". + /// Create an address from a dotted-decimal string. + /// + /// - Parameter fromString: An IPv4 address in dotted-decimal notation (e.g., "192.168.64.10") + /// - Throws: `NetworkAddressError.invalidStringAddress` if the string format is invalid + /// + /// ## Example: + /// ```swift + /// let address = try IPv4Address("10.0.0.1") + /// print(address.description) // "10.0.0.1" + /// ``` public init(_ fromString: String) throws { let split = fromString.components(separatedBy: ".") if split.count != 4 { @@ -37,8 +68,17 @@ public struct IPv4Address: Codable, CustomStringConvertible, Equatable, Sendable value = parsedValue } - /// Create an address from an array of four bytes in network order (big-endian), - /// such as [192, 168, 64, 10]. + /// Create an address from an array of four bytes in network order (big-endian). + /// + /// - Parameter fromNetworkBytes: An array of exactly 4 bytes representing the IPv4 address + /// - Throws: `NetworkAddressError.invalidNetworkByteAddress` if the array doesn't contain exactly 4 bytes + /// + /// ## Example: + /// ```swift + /// let bytes: [UInt8] = [192, 168, 1, 100] + /// let address = try IPv4Address(fromNetworkBytes: bytes) + /// print(address.description) // "192.168.1.100" + /// ``` public init(fromNetworkBytes: [UInt8]) throws { guard fromNetworkBytes.count == 4 else { throw NetworkAddressError.invalidNetworkByteAddress(address: fromNetworkBytes) @@ -51,12 +91,31 @@ public struct IPv4Address: Codable, CustomStringConvertible, Equatable, Sendable | UInt32(fromNetworkBytes[3]) } - /// Create an address from a 32-bit integer, such as 0xc0a8_400a. + /// Create an address from a 32-bit integer value. + /// + /// - Parameter fromValue: A 32-bit integer representing the IPv4 address in host byte order + /// + /// ## Example: + /// ```swift + /// let address = IPv4Address(fromValue: 0xc0a80164) // 192.168.1.100 + /// print(address.description) // "192.168.1.100" + /// ``` public init(fromValue: UInt32) { value = fromValue } - /// Retrieve the address as an array of bytes in network byte order. + /// Returns the address as an array of bytes in network byte order (big-endian). + /// + /// - Returns: An array of 4 bytes representing the IPv4 address + /// + /// This is useful for network programming where you need to send the address + /// over the network in the standard big-endian format. + /// + /// ## Example: + /// ```swift + /// let address = try IPv4Address("10.0.0.1") + /// let bytes = address.networkBytes // [10, 0, 0, 1] + /// ``` public var networkBytes: [UInt8] { [ UInt8((value >> 24) & 0xff), @@ -66,25 +125,58 @@ public struct IPv4Address: Codable, CustomStringConvertible, Equatable, Sendable ] } - /// Retrieve the address as a dotted decimal string. + /// Returns the address as a dotted-decimal string. + /// + /// This property provides the standard human-readable representation of the IPv4 address. public var description: String { networkBytes.map(String.init).joined(separator: ".") } - /// Create the base IPv4 address for a network that contains this - /// address and uses the specified subnet mask length. + /// Create the network base address for a subnet containing this address. + /// + /// - Parameter prefixLength: The subnet mask length (0-32 bits) + /// - Returns: The base IPv4 address of the network containing this address + /// + /// This method applies the subnet mask to get the network portion of the address, + /// setting all host bits to zero. + /// + /// ## Example: + /// ```swift + /// let address = try IPv4Address("192.168.1.150") + /// let network = address.prefix(prefixLength: 24) + /// print(network.description) // "192.168.1.0" + /// + /// let subnetwork = address.prefix(prefixLength: 28) + /// print(subnetwork.description) // "192.168.1.144" + /// ``` public func prefix(prefixLength: PrefixLength) -> IPv4Address { IPv4Address(fromValue: value & prefixLength.prefixMask32) } } +// MARK: - Codable Conformance extension IPv4Address { + /// Creates an IPv4Address from a JSON string representation. + /// + /// - Parameter decoder: The decoder to read data from + /// - Throws: `DecodingError` if the string is not a valid IPv4 address format + /// + /// The JSON representation uses the standard dotted-decimal string format. + /// + /// ## Example JSON: + /// ```json + /// "192.168.1.1" + /// ``` public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let text = try container.decode(String.self) try self.init(text) } + /// Encodes the IPv4Address as a JSON string in dotted-decimal format. + /// + /// - Parameter encoder: The encoder to write data to + /// - Throws: `EncodingError` if encoding fails public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(self.description) diff --git a/Sources/ContainerizationExtras/NetworkAddress.swift b/Sources/ContainerizationExtras/NetworkAddress.swift index c2b826c..d496a32 100644 --- a/Sources/ContainerizationExtras/NetworkAddress.swift +++ b/Sources/ContainerizationExtras/NetworkAddress.swift @@ -22,26 +22,31 @@ public enum NetworkAddressError: Swift.Error, Equatable, CustomStringConvertible case invalidAddressForSubnet(address: String, cidr: String) case invalidAddressRange(lower: String, upper: String) + /// Provides detailed, actionable error descriptions to help developers fix validation issues. public var description: String { switch self { case .invalidStringAddress(let address): - return "invalid IP address string \(address)" + return "Invalid IPv4 address format '\(address)'. Expected dotted-decimal notation with 4 octets (0-255) separated by dots, such as '192.168.1.1' or '10.0.0.255'." case .invalidNetworkByteAddress(let address): - return "invalid IP address bytes \(address)" + return "Invalid IPv4 address bytes \(address). Expected exactly 4 bytes with values in range 0-255, such as [192, 168, 1, 1]." case .invalidCIDR(let cidr): - return "invalid CIDR block: \(cidr)" + return "Invalid CIDR block '\(cidr)'. Expected format 'x.x.x.x/n' where x.x.x.x is a valid IPv4 address and n is a prefix length from 0-32, such as '192.168.1.0/24' or '10.0.0.0/8'." case .invalidAddressForSubnet(let address, let cidr): - return "invalid address \(address) for subnet \(cidr)" + return "Invalid address '\(address)' for subnet '\(cidr)'. The address must be within the network range defined by the CIDR block." case .invalidAddressRange(let lower, let upper): - return "invalid range for addresses \(lower) and \(upper)" + return "Invalid address range from '\(lower)' to '\(upper)'. The lower bound must be less than or equal to the upper bound, and both must be valid IPv4 addresses." } } } +/// Type alias for network prefix lengths (0-32 for IPv4, 0-48 for truncated IPv6). public typealias PrefixLength = UInt8 extension PrefixLength { /// Compute a bit mask that passes the suffix bits, given the network prefix mask length. + /// + /// For IPv4 addresses, this calculates the host portion mask. + /// - Returns: A 32-bit mask where host bits are set to 1 public var suffixMask32: UInt32 { if self <= 0 { return 0xffff_ffff @@ -50,11 +55,17 @@ extension PrefixLength { } /// Compute a bit mask that passes the prefix bits, given the network prefix mask length. + /// + /// For IPv4 addresses, this calculates the network portion mask. + /// - Returns: A 32-bit mask where network bits are set to 1 public var prefixMask32: UInt32 { ~self.suffixMask32 } /// Compute a bit mask that passes the suffix bits, given the network prefix mask length. + /// + /// For truncated IPv6 addresses (48-bit), this calculates the host portion mask. + /// - Returns: A 64-bit mask where host bits are set to 1 (masked to 48 bits) public var suffixMask48: UInt64 { if self <= 0 { return 0x0000_ffff_ffff_ffff @@ -63,6 +74,9 @@ extension PrefixLength { } /// Compute a bit mask that passes the prefix bits, given the network prefix mask length. + /// + /// For truncated IPv6 addresses (48-bit), this calculates the network portion mask. + /// - Returns: A 64-bit mask where network bits are set to 1 (masked to 48 bits) public var prefixMask48: UInt64 { ~self.suffixMask48 & 0x0000_ffff_ffff_ffff } diff --git a/Sources/ContainerizationExtras/Timeout.swift b/Sources/ContainerizationExtras/Timeout.swift index 42b6e55..4e81dc3 100644 --- a/Sources/ContainerizationExtras/Timeout.swift +++ b/Sources/ContainerizationExtras/Timeout.swift @@ -16,11 +16,83 @@ import Foundation -/// `Timeout` contains helpers to run an operation and error out if -/// the operation does not finish within a provided time. +/// Provides utilities for executing async operations with time constraints. +/// +/// `Timeout` helps ensure that long-running async operations don't hang indefinitely +/// by automatically canceling them after a specified duration. This is especially +/// useful for network operations, file I/O, or any async task that might block. +/// +/// ## Use Cases +/// - Network requests that might hang +/// - File operations on potentially slow storage +/// - Container or VM operations with unpredictable execution times +/// - Any async operation that needs guaranteed completion time +/// +/// ## Example usage: +/// ```swift +/// // Timeout a network request after 30 seconds +/// do { +/// let data = try await Timeout.run(seconds: 30) { +/// await networkClient.fetchData() +/// } +/// print("Request completed: \(data)") +/// } catch is CancellationError { +/// print("Request timed out after 30 seconds") +/// } +/// +/// // Timeout a container start operation +/// do { +/// let container = try await Timeout.run(seconds: 60) { +/// await containerManager.startContainer(id: "abc123") +/// } +/// print("Container started successfully") +/// } catch is CancellationError { +/// print("Container start timed out") +/// } +/// ``` public struct Timeout { - /// Performs the passed in `operation` and throws a `CancellationError` if the operation - /// doesn't finish in the provided `seconds` amount. + /// Executes an async operation with a timeout, canceling it if it doesn't complete in time. + /// + /// - Parameters: + /// - seconds: The maximum number of seconds to wait for the operation to complete + /// - operation: The async operation to execute with timeout protection + /// - Returns: The result of the operation if it completes within the timeout + /// - Throws: `CancellationError` if the operation doesn't complete within the specified time + /// + /// This method uses structured concurrency to race the provided operation against + /// a timer. If the operation completes first, its result is returned. If the timer + /// expires first, a `CancellationError` is thrown and any pending work is canceled. + /// + /// ## Implementation Details + /// - Uses `TaskGroup` for structured concurrency + /// - Automatically cancels remaining tasks when one completes + /// - The timeout precision is limited by the system's task scheduling + /// - Operations are not forcefully terminated - they receive a cancellation signal + /// + /// ## Example: + /// ```swift + /// // Simple timeout example + /// let result = try await Timeout.run(seconds: 5) { + /// await someAsyncOperation() + /// } + /// + /// // Handling timeout errors + /// do { + /// let data = try await Timeout.run(seconds: 10) { + /// await longRunningOperation() + /// } + /// handleSuccess(data) + /// } catch is CancellationError { + /// handleTimeout() + /// } catch { + /// handleOtherError(error) + /// } + /// ``` + /// + /// ## Performance Notes + /// - Minimal overhead when operations complete quickly + /// - Timer task is automatically cleaned up when operation completes + /// - Uses cooperative cancellation - operations must check for cancellation public static func run( seconds: UInt32, operation: @escaping @Sendable () async -> T @@ -36,7 +108,7 @@ public struct Timeout { } guard let result = try await group.next() else { - fatalError() + fatalError("TaskGroup.next() unexpectedly returned nil") } group.cancelAll() diff --git a/Tests/ContainerizationExtrasTests/TestAsyncLock.swift b/Tests/ContainerizationExtrasTests/TestAsyncLock.swift new file mode 100644 index 0000000..3eea5ba --- /dev/null +++ b/Tests/ContainerizationExtrasTests/TestAsyncLock.swift @@ -0,0 +1,420 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. All rights reserved. +// +// Licensed 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 +// +// https://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 Testing +import ContainerizationExtras +import Foundation + +struct TestAsyncLock { + + @Test func testBasicLocking() async throws { + let lock = AsyncLock() + + actor Counter { + private var value = 0 + + func increment() -> Int { + value += 1 + return value + } + + func getValue() -> Int { + return value + } + } + + let counter = Counter() + + let result = await lock.withLock { context in + await counter.increment() + } + + #expect(result == 1) + #expect(await counter.getValue() == 1) + } + + @Test func testSequentialAccess() async throws { + let lock = AsyncLock() + + actor ValueStore { + private var values: [Int] = [] + + func append(_ value: Int) { + values.append(value) + } + + func getValues() -> [Int] { + return values + } + } + + let store = ValueStore() + + // Execute operations sequentially + await lock.withLock { context in + await store.append(1) + } + + await lock.withLock { context in + await store.append(2) + } + + await lock.withLock { context in + await store.append(3) + } + + let values = await store.getValues() + #expect(values == [1, 2, 3]) + } + + @Test func testConcurrentAccess() async throws { + let lock = AsyncLock() + let expectedCount = 100 + + actor ValueStore { + private var values: [Int] = [] + + func append(_ value: Int) { + values.append(value) + } + + func getValues() -> [Int] { + return values + } + + func count() -> Int { + return values.count + } + } + + let store = ValueStore() + + // Create concurrent tasks that all try to modify the array + await withTaskGroup(of: Void.self) { group in + for i in 0.. [String] { + return results + } + + func count() -> Int { + return results.count + } + } + + let store = ResultStore() + + await withTaskGroup(of: Void.self) { group in + for i in 0..<5 { + group.addTask { + await lock.withLock { context in + // Simulate async work + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + await store.append("task-\(i)") + } + } + } + + await group.waitForAll() + } + + let count = await store.count() + #expect(count == 5) + + // Results should all be unique (no concurrent modification) + let results = await store.getResults() + #expect(Set(results).count == 5) + } + + @Test func testLockWithThrowingOperation() async throws { + let lock = AsyncLock() + + actor Counter { + private var value = 0 + + func increment() -> Int { + value += 1 + return value + } + + func getValue() -> Int { + return value + } + } + + let counter = Counter() + + struct TestError: Error {} + + do { + try await lock.withLock { context in + let _ = await counter.increment() + throw TestError() + } + #expect(Bool(false), "Should have thrown an error") + } catch is TestError { + // Expected error + } + + // Lock should still work after an error + let _ = await lock.withLock { context in + await counter.increment() + } + + let finalCount = await counter.getValue() + #expect(finalCount == 2) + } + + @Test func testLockReentrancyPrevention() async throws { + let lock = AsyncLock() + + actor ExecutionTracker { + private var order: [String] = [] + + func append(_ event: String) { + order.append(event) + } + + func getOrder() -> [String] { + return order + } + } + + let tracker = ExecutionTracker() + + await withTaskGroup(of: Void.self) { group in + // First task - holds lock for a while + group.addTask { + await lock.withLock { context in + await tracker.append("task1-start") + try? await Task.sleep(nanoseconds: 50_000_000) // 50ms + await tracker.append("task1-end") + } + } + + // Second task - should wait for first task + group.addTask { + // Small delay to ensure task1 starts first + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + await lock.withLock { context in + await tracker.append("task2-start") + await tracker.append("task2-end") + } + } + + await group.waitForAll() + } + + // Task 1 should complete entirely before task 2 starts + let executionOrder = await tracker.getOrder() + #expect(executionOrder == ["task1-start", "task1-end", "task2-start", "task2-end"]) + } + + @Test func testLockFIFOOrdering() async throws { + let lock = AsyncLock() + let taskCount = 10 + + actor ExecutionTracker { + private var order: [Int] = [] + + func append(_ value: Int) { + order.append(value) + } + + func getOrder() -> [Int] { + return order + } + } + + let tracker = ExecutionTracker() + + await withTaskGroup(of: Void.self) { group in + for i in 0.. [String] { + return results + } + + func count() -> Int { + return results.count + } + } + + let store = ResultStore() + + await withTaskGroup(of: Void.self) { group in + // Task using lock1 + group.addTask { + await lock1.withLock { context in + try? await Task.sleep(nanoseconds: 20_000_000) // 20ms + await store.append("lock1") + } + } + + // Task using lock2 (should run concurrently with lock1) + group.addTask { + await lock2.withLock { context in + try? await Task.sleep(nanoseconds: 20_000_000) // 20ms + await store.append("lock2") + } + } + + await group.waitForAll() + } + + // Both locks should have executed + let count = await store.count() + #expect(count == 2) + + let results = await store.getResults() + #expect(Set(results) == Set(["lock1", "lock2"])) + } + + @Test func testContextParameter() async throws { + let lock = AsyncLock() + + await lock.withLock { context in + // Context should be provided and be the correct type + _ = context // Just verify it exists and compiles + } + } + + @Test func testLockPerformance() async throws { + let lock = AsyncLock() + let iterations = 1000 + + actor Counter { + private var value = 0 + + func increment() -> Int { + value += 1 + return value + } + + func getValue() -> Int { + return value + } + } + + let counter = Counter() + let startTime = CFAbsoluteTimeGetCurrent() + + await withTaskGroup(of: Void.self) { group in + for _ in 0..= 0.9 && elapsed <= 1.5, "Timeout should occur around 1 second, got \(elapsed)") + } + } + + @Test func testZeroTimeout() async throws { + do { + let _ = try await Timeout.run(seconds: 0) { + return "immediate" + } + // With 0 timeout, either the operation completes immediately or times out + // Both are valid behaviors + } catch is CancellationError { + // Also valid - 0 timeout can immediately cancel + } + } + + // Test with a throwing operation wrapped in a non-throwing closure + @Test func testOperationThrowsError() async throws { + struct CustomError: Error, Equatable {} + + let result = try await Timeout.run(seconds: 5) { + // Simulate a throwing operation by returning a Result + return Result.failure(CustomError()) + } + + switch result { + case .success: + #expect(Bool(false), "Should have returned failure") + case .failure: + // Expected error result + break + } + } + + @Test func testOperationThrowsErrorBeforeTimeout() async throws { + struct QuickError: Error {} + + let startTime = CFAbsoluteTimeGetCurrent() + + let result = try await Timeout.run(seconds: 10) { + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + return Result.failure(QuickError()) + } + + let elapsed = CFAbsoluteTimeGetCurrent() - startTime + #expect(elapsed < 1.0, "Error should occur quickly, not after timeout") + + switch result { + case .success: + #expect(Bool(false), "Should have returned failure") + case .failure: + // Expected error result + break + } + } + + @Test func testConcurrentTimeouts() async throws { + let results = await withTaskGroup(of: Result.self, returning: [Result].self) { group in + // Mix of operations that succeed and timeout + for i in 0..<5 { + group.addTask { + do { + let result = try await Timeout.run(seconds: 1) { + if i % 2 == 0 { + // Even numbers succeed quickly + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + return "success-\(i)" + } else { + // Odd numbers timeout + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + return "timeout-\(i)" + } + } + return .success(result) + } catch { + return .failure(error) + } + } + } + + var results: [Result] = [] + for await result in group { + results.append(result) + } + return results + } + + #expect(results.count == 5) + + var successes = 0 + var timeouts = 0 + + for result in results { + switch result { + case .success(let value): + #expect(value.hasPrefix("success-")) + successes += 1 + case .failure(let error): + #expect(error is CancellationError) + timeouts += 1 + } + } + + #expect(successes == 3) // Even numbers: 0, 2, 4 + #expect(timeouts == 2) // Odd numbers: 1, 3 + } + + @Test func testComplexReturnType() async throws { + struct ComplexResult: Equatable, Sendable { + let id: Int + let name: String + + static func == (lhs: ComplexResult, rhs: ComplexResult) -> Bool { + return lhs.id == rhs.id && lhs.name == rhs.name + } + } + + let expected = ComplexResult(id: 123, name: "test") + + let result = try await Timeout.run(seconds: 5) { + return expected + } + + #expect(result == expected) + } + + @Test func testTimeoutAccuracy() async throws { + let timeoutSeconds: UInt32 = 2 + let startTime = CFAbsoluteTimeGetCurrent() + + do { + let _ = try await Timeout.run(seconds: timeoutSeconds) { + // Operation that definitely takes longer than timeout + try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds + return "should not complete" + } + #expect(Bool(false), "Should have timed out") + } catch is CancellationError { + let elapsed = CFAbsoluteTimeGetCurrent() - startTime + let expectedTimeout = Double(timeoutSeconds) + + // Allow 20% tolerance for timing accuracy + let tolerance = expectedTimeout * 0.2 + let minTime = expectedTimeout - tolerance + let maxTime = expectedTimeout + tolerance + + #expect(elapsed >= minTime && elapsed <= maxTime, + "Timeout should occur around \(expectedTimeout)s, got \(elapsed)s") + } + } + + @Test func testAsyncOperationInTimeout() async throws { + actor Counter { + private var value = 0 + + func increment() -> Int { + value += 1 + return value + } + + func getValue() -> Int { + return value + } + } + + let counter = Counter() + + let result = try await Timeout.run(seconds: 5) { + let value1 = await counter.increment() + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + let value2 = await counter.increment() + return (value1, value2) + } + + #expect(result.0 == 1) + #expect(result.1 == 2) + #expect(await counter.getValue() == 2) + } + + @Test func testTimeoutWithTaskCancellation() async throws { + let startTime = CFAbsoluteTimeGetCurrent() + + do { + let _ = try await Timeout.run(seconds: 1) { + // Operation that checks for cancellation + for _ in 0..<100 { + if Task.isCancelled { + return "cancelled" + } + try? await Task.sleep(nanoseconds: 50_000_000) // 50ms per iteration + } + return "completed" + } + // Either cancellation or completion is valid + } catch is CancellationError { + let elapsed = CFAbsoluteTimeGetCurrent() - startTime + #expect(elapsed <= 1.5, "Should be cancelled within timeout period") + } + } + + @Test func testLargeTimeout() async throws { + // Test with a very large timeout to ensure no overflow issues + let result = try await Timeout.run(seconds: UInt32.max) { + try? await Task.sleep(nanoseconds: 1_000_000) // 1ms + return "quick-result" + } + + #expect(result == "quick-result") + } + + @Test func testTimeoutPerformance() async throws { + let iterations = 100 + let startTime = CFAbsoluteTimeGetCurrent() + + for _ in 0..