diff --git a/Tests/SubprocessTests/ProcessMonitoringTests.swift b/Tests/SubprocessTests/ProcessMonitoringTests.swift index 3c0cce2a..039ba7bf 100644 --- a/Tests/SubprocessTests/ProcessMonitoringTests.swift +++ b/Tests/SubprocessTests/ProcessMonitoringTests.swift @@ -227,42 +227,44 @@ extension SubprocessProcessMonitoringTests { } @Test func testDoesNotReapUnrelatedChildProcess() async throws { - // Make sure we don't reap child exit status that we didn't spawn - let child1 = self.immediateExitProcess(withExitCode: 0) - let child2 = self.immediateExitProcess(withExitCode: 0) - try await withSpawnedExecution(config: child1) { child1Execution in - try await withSpawnedExecution(config: child2) { child2Execution in - // Monitor child2, but make sure we don't reap child1's status - let status = try await monitorProcessTermination( - for: child2Execution.processIdentifier - ) - #expect(status.isSuccess) - // Make sure we can still fetch child 1 - #if os(Windows) - let rc = WaitForSingleObject( - child1Execution.processIdentifier.processDescriptor, - INFINITE - ) - #expect(rc == WAIT_OBJECT_0) - var child1Status: DWORD = 0 - let rc2 = GetExitCodeProcess( - child1Execution.processIdentifier.processDescriptor, - &child1Status - ) - #expect(rc2 == true) - #expect(child1Status == 0) - #else - var siginfo = siginfo_t() - let rc = waitid( - P_PID, - id_t(child1Execution.processIdentifier.value), - &siginfo, - WEXITED - ) - #expect(rc == 0) - #expect(siginfo.si_code == CLD_EXITED) - #expect(siginfo.si_status == 0) - #endif + try await withTimeout(.seconds(60), description: #function) { + // Make sure we don't reap child exit status that we didn't spawn + let child1 = self.immediateExitProcess(withExitCode: 0) + let child2 = self.immediateExitProcess(withExitCode: 0) + try await withSpawnedExecution(config: child1) { child1Execution in + try await withSpawnedExecution(config: child2) { child2Execution in + // Monitor child2, but make sure we don't reap child1's status + let status = try await monitorProcessTermination( + for: child2Execution.processIdentifier + ) + #expect(status.isSuccess) + // Make sure we can still fetch child 1 + #if os(Windows) + let rc = WaitForSingleObject( + child1Execution.processIdentifier.processDescriptor, + INFINITE + ) + #expect(rc == WAIT_OBJECT_0) + var child1Status: DWORD = 0 + let rc2 = GetExitCodeProcess( + child1Execution.processIdentifier.processDescriptor, + &child1Status + ) + #expect(rc2 == true) + #expect(child1Status == 0) + #else + var siginfo = siginfo_t() + let rc = waitid( + P_PID, + id_t(child1Execution.processIdentifier.value), + &siginfo, + WEXITED + ) + #expect(rc == 0) + #expect(siginfo.si_code == CLD_EXITED) + #expect(siginfo.si_status == 0) + #endif + } } } } diff --git a/Tests/SubprocessTests/TestSupport.swift b/Tests/SubprocessTests/TestSupport.swift index c5325770..9f80c4b7 100644 --- a/Tests/SubprocessTests/TestSupport.swift +++ b/Tests/SubprocessTests/TestSupport.swift @@ -96,3 +96,51 @@ extension Trait where Self == ConditionTrait { ) } } + +// MARK: - Test Timeout + +// Adapted from swift-build to surface hangs in tests as diagnosable failures +// rather than CI job timeouts. +// https://github.com/swiftlang/swift-build/blob/main/Sources/SWBTestSupport/Timeout.swift + +internal struct TimeoutError: Error, CustomStringConvertible { + internal var description: String + + internal init(description: String) { + self.description = description + } +} + +@discardableResult +internal func withTimeout( + _ timeout: Duration, + description: String, + _ body: sending () async throws -> T +) async throws -> T { + try await withoutActuallyEscaping(body) { escapingClosure in + // This lets `body` be captured in the `Sendable` function `work`. + // since `work` is `Sendable`, we can smuggle it through. + nonisolated(unsafe) let smuggled = escapingClosure + let work: @Sendable () async throws -> T = { + try await smuggled() + } + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await work() + } + group.addTask { + try await Task.sleep(for: timeout) + throw TimeoutError( + description: "\(description) exceeded timeout of \(timeout)" + ) + } + defer { group.cancelAll() } + guard let result = try await group.next() else { + throw TimeoutError( + description: "\(description) exceeded timeout of \(timeout)" + ) + } + return result + } + } +}