diff --git a/Tests/SubprocessTests/SubprocessTests+Linux.swift b/Tests/SubprocessTests/SubprocessTests+Linux.swift index fc801d6..fd1ee03 100644 --- a/Tests/SubprocessTests/SubprocessTests+Linux.swift +++ b/Tests/SubprocessTests/SubprocessTests+Linux.swift @@ -46,52 +46,97 @@ struct SubprocessLinuxTests { } @Test func testSuspendResumeProcess() async throws { - func isProcessSuspended(_ pid: pid_t) throws -> Bool { - let status = try Data( - contentsOf: URL(filePath: "/proc/\(pid)/status") - ) - let statusString = try #require( - String(data: status, encoding: .utf8) - ) - // Parse the status string - let stats = statusString.split(separator: "\n") - if let index = stats.firstIndex( - where: { $0.hasPrefix("State:") } - ) { - let processState = stats[index].split( - separator: ":" - ).map { - $0.trimmingCharacters( - in: .whitespacesAndNewlines - ) - } - - return processState[1].hasPrefix("T") - } - return false - } - _ = try await Subprocess.run( // This will intentionally hang .path("/usr/bin/sleep"), arguments: ["infinity"], error: .discarded ) { subprocess, standardOutput in - // First suspend the process - try subprocess.send(signal: .suspend) - #expect( - try isProcessSuspended(subprocess.processIdentifier.value) - ) - // Now resume the process - try subprocess.send(signal: .resume) - #expect( - try isProcessSuspended(subprocess.processIdentifier.value) == false - ) - // Now kill the process - try subprocess.send(signal: .terminate) - for try await _ in standardOutput {} + try await tryFinally { + // First suspend the process + try subprocess.send(signal: .suspend) + try await waitForCondition(timeout: .seconds(30)) { + let state = try subprocess.state() + return state == .stopped + } + // Now resume the process + try subprocess.send(signal: .resume) + try await waitForCondition(timeout: .seconds(30)) { + let state = try subprocess.state() + return state == .running + } + } finally: { error in + // Now kill the process + try subprocess.send(signal: error != nil ? .kill : .terminate) + for try await _ in standardOutput {} + } } } } +fileprivate enum ProcessState: String { + case running = "R" + case sleeping = "S" + case uninterruptibleWait = "D" + case zombie = "Z" + case stopped = "T" +} + +extension Execution { + fileprivate func state() throws -> ProcessState { + let processStatusFile = "/proc/\(processIdentifier.value)/status" + let processStatusData = try Data( + contentsOf: URL(filePath: processStatusFile) + ) + let stateMatches = try String(decoding: processStatusData, as: UTF8.self) + .split(separator: "\n") + .compactMap({ line in + return try #/^State:\s+(?[A-Z])\s+.*/#.wholeMatch(in: line) + }) + guard let status = stateMatches.first, stateMatches.count == 1, let processState = ProcessState(rawValue: String(status.output.status)) else { + struct ProcStatusParseError: Error, CustomStringConvertible { + let filePath: String + let contents: Data + var description: String { + "Could not parse \(filePath):\n\(String(decoding: contents, as: UTF8.self))" + } + } + throw ProcStatusParseError(filePath: processStatusFile, contents: processStatusData) + } + return processState + } +} + +func waitForCondition(timeout: Duration, _ evaluateCondition: () throws -> Bool) async throws { + var currentCondition = try evaluateCondition() + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + if currentCondition { + return + } + try await Task.sleep(for: .milliseconds(10)) + currentCondition = try evaluateCondition() + } + struct TimeoutError: Error, CustomStringConvertible { + var description: String { + "Timed out waiting for condition to be true" + } + } + throw TimeoutError() +} + +func tryFinally(_ work: () async throws -> (), finally: (Error?) async throws -> ()) async throws { + let error: Error? + do { + try await work() + error = nil + } catch let e { + error = e + } + try await finally(error) + if let error { + throw error + } +} + #endif // canImport(Glibc) || canImport(Bionic) || canImport(Musl)