From b5ffa5c3d535a05b533bfe599c87fb98496be9ec Mon Sep 17 00:00:00 2001 From: broken-circle <252359939+broken-circle@users.noreply.github.com> Date: Sun, 17 May 2026 13:27:07 -0700 Subject: [PATCH] Add `withTimeout()` to surface hangs as diagnosable failures Some tests such as `testDoesNotReapUnrelatedChildProcess()` non- deterministically hang, consuming the full CI job timeout with no actionable signal. Surface the hang as a `TimeoutError` with the test name and timeout duration instead. --- .../ProcessMonitoringTests.swift | 74 ++++++++++--------- Tests/SubprocessTests/TestSupport.swift | 48 ++++++++++++ 2 files changed, 86 insertions(+), 36 deletions(-) diff --git a/Tests/SubprocessTests/ProcessMonitoringTests.swift b/Tests/SubprocessTests/ProcessMonitoringTests.swift index 3c0cce2..039ba7b 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 c532577..9f80c4b 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 + } + } +}