diff --git a/Foundation/Process.swift b/Foundation/Process.swift index 50abc12285..47ba49b950 100644 --- a/Foundation/Process.swift +++ b/Foundation/Process.swift @@ -355,12 +355,12 @@ open class Process: NSObject { } while ( (waitResult == -1) && (errno == EINTR) ) if WIFSIGNALED(exitCode) { - process.terminationStatus = WTERMSIG(exitCode) - process.terminationReason = .uncaughtSignal + process._terminationStatus = WTERMSIG(exitCode) + process._terminationReason = .uncaughtSignal } else { assert(WIFEXITED(exitCode)) - process.terminationStatus = WEXITSTATUS(exitCode) - process.terminationReason = .exit + process._terminationStatus = WEXITSTATUS(exitCode) + process._terminationReason = .exit } // If a termination handler has been set, invoke it on a background thread @@ -374,7 +374,6 @@ open class Process: NSObject { // Set the running flag to false process.isRunning = false - process.processIdentifier = -1 // Invalidate the source and wake up the run loop, if it's available @@ -507,19 +506,59 @@ open class Process: NSObject { self.processIdentifier = pid } - open func interrupt() { NSUnimplemented() } // Not always possible. Sends SIGINT. - open func terminate() { NSUnimplemented() }// Not always possible. Sends SIGTERM. - - open func suspend() -> Bool { NSUnimplemented() } - open func resume() -> Bool { NSUnimplemented() } + open func interrupt() { + precondition(hasStarted, "task not launched") + kill(processIdentifier, SIGINT) + } + + open func terminate() { + precondition(hasStarted, "task not launched") + kill(processIdentifier, SIGTERM) + } + + // Every suspend() has to be balanced with a resume() so keep a count of both. + private var suspendCount = 0 + + open func suspend() -> Bool { + if kill(processIdentifier, SIGSTOP) == 0 { + suspendCount += 1 + return true + } else { + return false + } + } + + open func resume() -> Bool { + var success = true + if suspendCount == 1 { + success = kill(processIdentifier, SIGCONT) == 0 + } + if success { + suspendCount -= 1 + } + return success + } // status - open private(set) var processIdentifier: Int32 = -1 + open private(set) var processIdentifier: Int32 = 0 open private(set) var isRunning: Bool = false - - open private(set) var terminationStatus: Int32 = 0 - open private(set) var terminationReason: TerminationReason = .exit - + private var hasStarted: Bool { return processIdentifier > 0 } + private var hasFinished: Bool { return !isRunning && processIdentifier > 0 } + + private var _terminationStatus: Int32 = 0 + public var terminationStatus: Int32 { + precondition(hasStarted, "task not launched") + precondition(hasFinished, "task still running") + return _terminationStatus + } + + private var _terminationReason: TerminationReason = .exit + public var terminationReason: TerminationReason { + precondition(hasStarted, "task not launched") + precondition(hasFinished, "task still running") + return _terminationReason + } + /* A block to be invoked when the process underlying the Process terminates. Setting the block to nil is valid, and stops the previous block from being invoked, as long as it hasn't started in any way. The Process is passed as the argument to the block so the block does not have to capture, and thus retain, it. The block is copied when set. Only one termination handler block can be set at any time. The execution context in which the block is invoked is undefined. If the Process has already finished, the block is executed immediately/soon (not necessarily on the current thread). If a terminationHandler is set on an Process, the ProcessDidTerminateNotification notification is not posted for that process. Also note that -waitUntilExit won't wait until the terminationHandler has been fully executed. You cannot use this property in a concrete subclass of Process which hasn't been updated to include an implementation of the storage and use of it. */ diff --git a/TestFoundation/TestProcess.swift b/TestFoundation/TestProcess.swift index 4690f07c34..9f4b61ded0 100644 --- a/TestFoundation/TestProcess.swift +++ b/TestFoundation/TestProcess.swift @@ -29,6 +29,10 @@ class TestProcess : XCTestCase { ("test_no_environment", test_no_environment), ("test_custom_environment", test_custom_environment), ("test_run", test_run), + ("test_preStartEndState", test_preStartEndState), + ("test_interrupt", test_interrupt), + ("test_terminate", test_terminate), + ("test_suspend_resume", test_suspend_resume), ] #endif } @@ -385,6 +389,135 @@ class TestProcess : XCTestCase { fm.changeCurrentDirectoryPath(cwd) } + func test_preStartEndState() { + let process = Process() + XCTAssertNil(process.executableURL) + XCTAssertNotNil(process.currentDirectoryURL) + XCTAssertNil(process.arguments) + XCTAssertNil(process.environment) + XCTAssertFalse(process.isRunning) + XCTAssertEqual(process.processIdentifier, 0) + XCTAssertEqual(process.qualityOfService, .default) + + process.executableURL = URL(fileURLWithPath: "/bin/cat", isDirectory: false) + _ = try? process.run() + XCTAssertTrue(process.isRunning) + XCTAssertTrue(process.processIdentifier > 0) + process.terminate() + process.waitUntilExit() + XCTAssertFalse(process.isRunning) + XCTAssertTrue(process.processIdentifier > 0) + XCTAssertEqual(process.terminationReason, .uncaughtSignal) + XCTAssertEqual(process.terminationStatus, SIGTERM) + } + + func test_interrupt() { + let helper = _SignalHelperRunner() + do { + try helper.start() + } catch { + XCTFail("Cant run xdgTestHelper: \(error)") + return + } + if !helper.waitForReady() { + XCTFail("Didnt receive Ready from sub-process") + return + } + + let now = DispatchTime.now().uptimeNanoseconds + let timeout = DispatchTime(uptimeNanoseconds: now + 2_000_000_000) + + var count = 3 + while count > 0 { + helper.process.interrupt() + guard helper.semaphore.wait(timeout: timeout) == .success else { + helper.process.terminate() + XCTFail("Timedout waiting for signal") + return + } + + if helper.sigIntCount == 3 { + break + } + count -= 1 + } + helper.process.terminate() + XCTAssertEqual(helper.sigIntCount, 3) + helper.process.waitUntilExit() + let terminationReason = helper.process.terminationReason + XCTAssertEqual(terminationReason, Process.TerminationReason.exit) + let status = helper.process.terminationStatus + XCTAssertEqual(status, 99) + } + + func test_terminate() { + let cat = URL(fileURLWithPath: "/bin/cat", isDirectory: false) + guard let process = try? Process.run(cat, arguments: []) else { + XCTFail("Cant run /bin/cat") + return + } + + process.terminate() + process.waitUntilExit() + let terminationReason = process.terminationReason + XCTAssertEqual(terminationReason, Process.TerminationReason.uncaughtSignal) + XCTAssertEqual(process.terminationStatus, SIGTERM) + } + + func test_suspend_resume() { + let helper = _SignalHelperRunner() + do { + try helper.start() + } catch { + XCTFail("Cant run xdgTestHelper: \(error)") + return + } + if !helper.waitForReady() { + XCTFail("Didnt receive Ready from sub-process") + return + } + let now = DispatchTime.now().uptimeNanoseconds + let timeout = DispatchTime(uptimeNanoseconds: now + 2_000_000_000) + + func waitForSemaphore() -> Bool { + guard helper.semaphore.wait(timeout: timeout) == .success else { + helper.process.terminate() + XCTFail("Timedout waiting for signal") + return false + } + return true + } + + XCTAssertTrue(helper.process.isRunning) + XCTAssertTrue(helper.process.suspend()) + XCTAssertTrue(helper.process.isRunning) + XCTAssertTrue(helper.process.resume()) + if waitForSemaphore() == false { return } + XCTAssertEqual(helper.sigContCount, 1) + + XCTAssertTrue(helper.process.resume()) + XCTAssertTrue(helper.process.suspend()) + XCTAssertTrue(helper.process.resume()) + XCTAssertEqual(helper.sigContCount, 1) + + XCTAssertTrue(helper.process.suspend()) + XCTAssertTrue(helper.process.suspend()) + XCTAssertTrue(helper.process.resume()) + if waitForSemaphore() == false { return } + + helper.process.suspend() + helper.process.resume() + if waitForSemaphore() == false { return } + XCTAssertEqual(helper.sigContCount, 3) + + helper.process.terminate() + helper.process.waitUntilExit() + XCTAssertFalse(helper.process.isRunning) + XCTAssertFalse(helper.process.suspend()) + XCTAssertTrue(helper.process.resume()) + XCTAssertTrue(helper.process.resume()) + } + #endif } @@ -394,6 +527,89 @@ private enum Error: Swift.Error { case InvalidEnvironmentVariable(String) } +// Run xdgTestHelper, wait for 'Ready' from the sub-process, then signal a semaphore. +// Read lines from a pipe and store in a queue. +class _SignalHelperRunner { + let process = Process() + let semaphore = DispatchSemaphore(value: 0) + + private let outputPipe = Pipe() + private let sQueue = DispatchQueue(label: "io queue") + private let source: DispatchSourceRead + + private var gotReady = false + private var bytesIn = Data() + private var _sigIntCount = 0 + private var _sigContCount = 0 + var sigIntCount: Int { return sQueue.sync { return _sigIntCount } } + var sigContCount: Int { return sQueue.sync { return _sigContCount } } + + + init() { + process.executableURL = xdgTestHelperURL() + process.environment = ProcessInfo.processInfo.environment + process.arguments = ["--signal-test"] + process.standardOutput = outputPipe.fileHandleForWriting + + source = DispatchSource.makeReadSource(fileDescriptor: outputPipe.fileHandleForReading.fileDescriptor, queue: sQueue) + let workItem = DispatchWorkItem(block: { [weak self] in + if let strongSelf = self { + let newLine = UInt8(ascii: "\n") + + strongSelf.bytesIn.append(strongSelf.outputPipe.fileHandleForReading.availableData) + if strongSelf.bytesIn.isEmpty { + return + } + // Split the incoming data into lines. + while let index = strongSelf.bytesIn.index(of: newLine) { + if index >= strongSelf.bytesIn.startIndex { + // dont include the newline when converting to string + let line = String(data: strongSelf.bytesIn[strongSelf.bytesIn.startIndex.. Bool { + let now = DispatchTime.now().uptimeNanoseconds + let timeout = DispatchTime(uptimeNanoseconds: now + 2_000_000_000) + guard semaphore.wait(timeout: timeout) == .success else { + process.terminate() + return false + } + return true + } +} + #if !os(Android) private func runTask(_ arguments: [String], environment: [String: String]? = nil, currentDirectoryPath: String? = nil) throws -> (String, String) { let process = Process() diff --git a/TestFoundation/xdgTestHelper/main.swift b/TestFoundation/xdgTestHelper/main.swift index 86faf742b1..7435c604e2 100644 --- a/TestFoundation/xdgTestHelper/main.swift +++ b/TestFoundation/xdgTestHelper/main.swift @@ -1,6 +1,6 @@ // This source file is part of the Swift.org open source project // -// Copyright (c) 2017 Swift project authors +// Copyright (c) 2017 - 2018 Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See http://swift.org/LICENSE.txt for license information @@ -55,15 +55,72 @@ class XDGCheck { } } -if let arg = ProcessInfo.processInfo.arguments.last { - if arg == "--xdgcheck" { - XDGCheck.run() - } - if arg == "--getcwd" { - print(FileManager.default.currentDirectoryPath) +// Used by TestProcess: test_interrupt(), test_suspend_resume() +func signalTest() { + + var signalSet = sigset_t() + sigemptyset(&signalSet) + sigaddset(&signalSet, SIGTERM) + sigaddset(&signalSet, SIGCONT) + sigaddset(&signalSet, SIGINT) + sigaddset(&signalSet, SIGALRM) + guard sigprocmask(SIG_BLOCK, &signalSet, nil) == 0 else { + fatalError("Cant block signals") } - if arg == "--echo-PWD" { - print(ProcessInfo.processInfo.environment["PWD"] ?? "") + // Timeout + alarm(3) + + // On Linux, print() doesnt currently flush the output over the pipe so use + // write() for now. On macOS, print() works fine. + write(1, "Ready\n", 6) + + while true { + var receivedSignal: Int32 = 0 + let ret = sigwait(&signalSet, &receivedSignal) + guard ret == 0 else { + fatalError("sigwait() failed") + } + switch receivedSignal { + case SIGINT: + write(1, "Signal: SIGINT\n", 15) + + case SIGCONT: + write(1, "Signal: SIGCONT\n", 16) + + case SIGTERM: + print("Terminated") + exit(99) + + case SIGALRM: + print("Timedout") + exit(127) + + default: + let msg = "Unexpected signal: \(receivedSignal)" + fatalError(msg) + } } } +var arguments = ProcessInfo.processInfo.arguments.dropFirst().makeIterator() + +guard let arg = arguments.next() else { + fatalError("The unit test must specify the correct number of flags and arguments.") +} + +switch arg { +case "--xdgcheck": + XDGCheck.run() + +case "--getcwd": + print(FileManager.default.currentDirectoryPath) + +case "--echo-PWD": + print(ProcessInfo.processInfo.environment["PWD"] ?? "") + +case "--signal-test": + signalTest() + +default: + fatalError("These arguments are not recognized. Only run this from a unit test.") +}