diff --git a/Package.swift b/Package.swift index d6b3b6f87..f7fb5dc5f 100644 --- a/Package.swift +++ b/Package.swift @@ -131,6 +131,7 @@ extension Array where Element == PackageDescription.SwiftSetting { .define("SWT_TARGET_OS_APPLE", .when(platforms: [.macOS, .iOS, .macCatalyst, .watchOS, .tvOS, .visionOS])), .define("SWT_NO_EXIT_TESTS", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), + .define("SWT_NO_PROCESS_SPAWNING", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), .define("SWT_NO_SNAPSHOT_TYPES", .when(platforms: [.linux, .windows, .wasi])), .define("SWT_NO_DYNAMIC_LINKING", .when(platforms: [.wasi])), .define("SWT_NO_PIPES", .when(platforms: [.wasi])), @@ -164,6 +165,7 @@ extension Array where Element == PackageDescription.CXXSetting { result += [ .define("SWT_NO_EXIT_TESTS", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), + .define("SWT_NO_PROCESS_SPAWNING", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), .define("SWT_NO_SNAPSHOT_TYPES", .when(platforms: [.linux, .windows, .wasi])), .define("SWT_NO_DYNAMIC_LINKING", .when(platforms: [.wasi])), .define("SWT_NO_PIPES", .when(platforms: [.wasi])), diff --git a/Sources/Testing/ExitTests/ExitCondition.swift b/Sources/Testing/ExitTests/ExitCondition.swift index 75d13ea75..dd1ffdcb5 100644 --- a/Sources/Testing/ExitTests/ExitCondition.swift +++ b/Sources/Testing/ExitTests/ExitCondition.swift @@ -18,8 +18,10 @@ private import _TestingInternals /// ``require(exitsWith:_:sourceLocation:performing:)`` to configure which exit /// statuses should be considered successful. @_spi(Experimental) -#if SWT_NO_EXIT_TESTS +#if SWT_NO_PROCESS_SPAWNING @available(*, unavailable, message: "Exit tests are not available on this platform.") +#elseif SWT_NO_EXIT_TESTS +@_spi(ForToolsIntegrationOnly) #endif public enum ExitCondition: Sendable { /// The process terminated successfully with status `EXIT_SUCCESS`. @@ -78,8 +80,10 @@ public enum ExitCondition: Sendable { // MARK: - Equatable -#if SWT_NO_EXIT_TESTS +#if SWT_NO_PROCESS_SPAWNING @available(*, unavailable, message: "Exit tests are not available on this platform.") +#elseif SWT_NO_EXIT_TESTS +@_spi(ForToolsIntegrationOnly) #endif extension ExitCondition { /// Check whether or not two values of this type are equal. @@ -108,9 +112,7 @@ extension ExitCondition { /// /// For any values `a` and `b`, `a == b` implies that `a != b` is `false`. public static func ==(lhs: Self, rhs: Self) -> Bool { -#if SWT_NO_EXIT_TESTS - fatalError("Unsupported") -#else +#if !SWT_NO_PROCESS_SPAWNING return switch (lhs, rhs) { case let (.failure, .exitCode(exitCode)), let (.exitCode(exitCode), .failure): exitCode != EXIT_SUCCESS @@ -122,6 +124,8 @@ extension ExitCondition { default: lhs === rhs } +#else + fatalError("Unsupported") #endif } @@ -152,10 +156,10 @@ extension ExitCondition { /// /// For any values `a` and `b`, `a == b` implies that `a != b` is `false`. public static func !=(lhs: Self, rhs: Self) -> Bool { -#if SWT_NO_EXIT_TESTS - fatalError("Unsupported") -#else +#if !SWT_NO_PROCESS_SPAWNING !(lhs == rhs) +#else + fatalError("Unsupported") #endif } @@ -226,10 +230,10 @@ extension ExitCondition { /// /// For any values `a` and `b`, `a === b` implies that `a !== b` is `false`. public static func !==(lhs: Self, rhs: Self) -> Bool { -#if SWT_NO_EXIT_TESTS - fatalError("Unsupported") -#else +#if !SWT_NO_PROCESS_SPAWNING !(lhs === rhs) +#else + fatalError("Unsupported") #endif } } diff --git a/Sources/Testing/ExitTests/SpawnProcess.swift b/Sources/Testing/ExitTests/SpawnProcess.swift index cb8fb8071..8a079d0cc 100644 --- a/Sources/Testing/ExitTests/SpawnProcess.swift +++ b/Sources/Testing/ExitTests/SpawnProcess.swift @@ -10,7 +10,7 @@ internal import _TestingInternals -#if !SWT_NO_EXIT_TESTS +#if !SWT_NO_PROCESS_SPAWNING /// A platform-specific value identifying a process running on the current /// system. #if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) diff --git a/Sources/Testing/ExitTests/WaitFor.swift b/Sources/Testing/ExitTests/WaitFor.swift index 86c986ed4..e2c5df4b2 100644 --- a/Sources/Testing/ExitTests/WaitFor.swift +++ b/Sources/Testing/ExitTests/WaitFor.swift @@ -8,8 +8,7 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // -#if !SWT_NO_EXIT_TESTS - +#if !SWT_NO_PROCESS_SPAWNING internal import _TestingInternals #if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) @@ -41,8 +40,45 @@ private func _blockAndWait(for pid: consuming pid_t) throws -> ExitCondition { } } } +#endif + +#if SWT_TARGET_OS_APPLE && !SWT_NO_LIBDISPATCH +/// Asynchronously wait for a process to terminate using a dispatch source. +/// +/// - Parameters: +/// - processID: The ID of the process to wait for. +/// +/// - Returns: The exit condition of `processID`. +/// +/// - Throws: If the exit status of the process with ID `processID` cannot be +/// determined (i.e. it does not represent an exit condition.) +/// +/// This implementation of `wait(for:)` suspends the calling task until +/// libdispatch reports that `processID` has terminated, then synchronously +/// calls `_blockAndWait(for:)` (which should not block because `processID` will +/// have already terminated by that point.) +/// +/// - Note: The open-source implementation of libdispatch available on Linux +/// and other platforms does not support `DispatchSourceProcess`. Those +/// platforms use an alternate implementation below. +func wait(for pid: consuming pid_t) async throws -> ExitCondition { + let pid = consume pid + + let source = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit) + defer { + source.cancel() + } + await withCheckedContinuation { continuation in + source.setEventHandler { + continuation.resume() + } + source.resume() + } + withExtendedLifetime(source) {} -#if !(SWT_TARGET_OS_APPLE && !SWT_NO_LIBDISPATCH) + return try _blockAndWait(for: pid) +} +#elseif SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) /// A mapping of awaited child PIDs to their corresponding Swift continuations. private let _childProcessContinuations = Locked<[pid_t: CheckedContinuation]>() @@ -54,8 +90,9 @@ private nonisolated(unsafe) let _waitThreadNoChildrenCondition = { return result }() -/// The implementation of `_createWaitThread()`, run only once. -private let _createWaitThreadImpl: Void = { +/// Create a waiter thread that is responsible for waiting for child processes +/// to exit. +private let _createWaitThread: Void = { // The body of the thread's run loop. func waitForAnyChild() { // Listen for child process exit events. WNOWAIT means we don't perturb the @@ -128,42 +165,29 @@ private let _createWaitThreadImpl: Void = { ) }() -/// Create a waiter thread that is responsible for waiting for child processes -/// to exit. -private func _createWaitThread() { - _createWaitThreadImpl -} -#endif - -/// Wait for a given PID to exit and report its status. +/// Asynchronously wait for a process to terminate using a background thread +/// that calls `waitid()` in a loop. /// /// - Parameters: -/// - pid: The PID to wait for. +/// - processID: The ID of the process to wait for. +/// +/// - Returns: The exit condition of `processID`. /// -/// - Returns: The exit condition of `pid`. +/// - Throws: If the exit status of the process with ID `processID` cannot be +/// determined (i.e. it does not represent an exit condition.) +/// +/// This implementation of `wait(for:)` suspends the calling task until +/// `waitid()`, called on a shared background thread, reports that `processID` +/// has terminated, then calls `_blockAndWait(for:)` (which should not block +/// because `processID` will have already terminated by that point.) /// -/// - Throws: Any error encountered calling `waitpid()` except for `EINTR`, -/// which is ignored. +/// On Apple platforms, the libdispatch-based implementation above is more +/// efficient because it does not need to permanently reserve a thread. func wait(for pid: consuming pid_t) async throws -> ExitCondition { let pid = consume pid -#if SWT_TARGET_OS_APPLE && !SWT_NO_LIBDISPATCH - let source = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit) - defer { - source.cancel() - } - await withCheckedContinuation { continuation in - source.setEventHandler { - continuation.resume() - } - source.resume() - } - withExtendedLifetime(source) {} - - return try _blockAndWait(for: pid) -#else // Ensure the waiter thread is running. - _createWaitThread() + _createWaitThread return try await withCheckedThrowingContinuation { continuation in _childProcessContinuations.withLock { childProcessContinuations in @@ -179,19 +203,23 @@ func wait(for pid: consuming pid_t) async throws -> ExitCondition { _ = pthread_cond_signal(_waitThreadNoChildrenCondition) } } -#endif } #elseif os(Windows) -/// Wait for a given process handle to exit and report its status. +/// Asynchronously wait for a process to terminate using the Windows thread +/// pool. /// /// - Parameters: -/// - processHandle: The handle to wait for. This function takes ownership of -/// this handle and closes it when done. +/// - processHandle: A Windows handle representing the process to wait for. +/// This handle is closed before the function returns. /// /// - Returns: The exit condition of `processHandle`. /// -/// - Throws: Any error encountered calling `WaitForSingleObject()` or +/// - Throws: Any error encountered calling `RegisterWaitForSingleObject()` or /// `GetExitCodeProcess()`. +/// +/// This implementation of `wait(for:)` calls `RegisterWaitForSingleObject()` to +/// wait for `processHandle`, suspends the calling task until the waiter's +/// callback is called, then calls `GetExitCodeProcess()`. func wait(for processHandle: consuming HANDLE) async throws -> ExitCondition { let processHandle = consume processHandle defer { @@ -235,5 +263,8 @@ func wait(for processHandle: consuming HANDLE) async throws -> ExitCondition { // FIXME: handle SEH/VEH uncaught exceptions. return .exitCode(CInt(bitPattern: .init(status))) } +#else +#warning("Platform-specific implementation missing: cannot wait for child processes to exit") +func wait(for processID: consuming Never) async throws -> ExitCondition {} #endif #endif diff --git a/cmake/modules/shared/CompilerSettings.cmake b/cmake/modules/shared/CompilerSettings.cmake index 9b59963fc..6bc49c7ba 100644 --- a/cmake/modules/shared/CompilerSettings.cmake +++ b/cmake/modules/shared/CompilerSettings.cmake @@ -24,6 +24,10 @@ set(SWT_NO_EXIT_TESTS_LIST "iOS" "watchOS" "tvOS" "visionOS" "WASI" "Android") if(CMAKE_SYSTEM_NAME IN_LIST SWT_NO_EXIT_TESTS_LIST) add_compile_definitions("SWT_NO_EXIT_TESTS") endif() +set(SWT_NO_PROCESS_SPAWNING_LIST "iOS" "watchOS" "tvOS" "visionOS" "WASI" "Android") +if(CMAKE_SYSTEM_NAME IN_LIST SWT_NO_PROCESS_SPAWNING_LIST) + add_compile_definitions("SWT_NO_PROCESS_SPAWNING") +endif() if(NOT APPLE) add_compile_definitions("SWT_NO_SNAPSHOT_TYPES") endif()