blob: 7b32a7890c7c87c1c75a00682ac83fb9e783b4fc [file] [log] [blame]
// Copyright 2016 The Tulsi Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
/// Encapsulates functionality to launch and manage NSTasks.
public final class TaskRunner {
/// Information retrieved through execution of a task.
public struct CompletionInfo {
/// The task that was executed.
public let task: Process
/// The commandline that was executed, suitable for pasting in terminal to reproduce.
public let commandlineString: String
/// The task's standard output.
public let stdout: Data
/// The task's standard error.
public let stderr: Data
/// The exit status for the task.
public var terminationStatus: Int32 {
return task.terminationStatus
}
}
public typealias CompletionHandler = (CompletionInfo) -> Void
private static var defaultInstance: TaskRunner = {
TaskRunner()
}()
/// The outstanding tasks.
private var pendingTasks = Set<Process>()
private let taskReader: TaskOutputReader
/// Prepares an NSTask using the given launch binary with the given arguments that will collect
/// output and passing it to a terminationHandler.
public static func createTask(_ launchPath: String,
arguments: [String]? = nil,
environment: [String: String]? = nil,
terminationHandler: @escaping CompletionHandler) -> Process {
return defaultInstance.createTask(launchPath,
arguments: arguments,
environment: environment,
terminationHandler: terminationHandler)
}
// MARK: - Private methods
private init() {
taskReader = TaskOutputReader()
taskReader.start()
}
deinit {
taskReader.stop()
}
private func createTask(_ launchPath: String,
arguments: [String]? = nil,
environment: [String: String]? = nil,
terminationHandler: @escaping CompletionHandler) -> Process {
let task = Process()
task.launchPath = launchPath
task.arguments = arguments
if let environment = environment {
task.environment = environment
}
let dispatchGroup = DispatchGroup()
let notificationCenter = NotificationCenter.default
func registerAndStartReader(_ fileHandle: FileHandle, outputData: NSMutableData) -> NSObjectProtocol {
let observer = notificationCenter.addObserver(forName: NSNotification.Name.NSFileHandleReadToEndOfFileCompletion,
object: fileHandle,
queue: nil) { (notification: Notification) in
defer { dispatchGroup.leave() }
if let err = notification.userInfo?["NSFileHandleError"] as? NSNumber {
assertionFailure("Read from pipe failed with error \(err)")
}
guard let data = notification.userInfo?[NSFileHandleNotificationDataItem] as? Data else {
assertionFailure("Unexpectedly received no data in read handler")
return
}
outputData.append(data)
}
dispatchGroup.enter()
// The docs for readToEndOfFileInBackgroundAndNotify are unclear as to exactly what work is
// done on the calling thread. By observation, it appears that data will not be read if the
// main queue is in event tracking mode.
let selector = #selector(FileHandle.readToEndOfFileInBackgroundAndNotify as (FileHandle) -> () -> Void)
fileHandle.perform(selector, on: taskReader.thread, with: nil, waitUntilDone: true)
return observer
}
let stdoutData = NSMutableData()
task.standardOutput = Pipe()
let stdoutObserver = registerAndStartReader((task.standardOutput! as AnyObject).fileHandleForReading,
outputData: stdoutData)
let stderrData = NSMutableData()
task.standardError = Pipe()
let stderrObserver = registerAndStartReader((task.standardError! as AnyObject).fileHandleForReading,
outputData: stderrData)
task.terminationHandler = { (task: Process) -> Void in
// The termination handler's thread is used to allow the caller's callback to do off-main work
// as well.
assert(!Thread.isMainThread,
"Task termination handler unexpectedly called on main thread.")
_ = dispatchGroup.wait(timeout: DispatchTime.distantFuture)
// Construct a string suitable for cutting and pasting into the commandline.
let commandlineArguments: String
if let arguments = arguments {
commandlineArguments = " " + arguments.map({ "\"\($0)\"" }).joined(separator: " ")
} else {
commandlineArguments = ""
}
let commandlineRunnableString = "\"\(task.launchPath!)\"\(commandlineArguments)"
terminationHandler(CompletionInfo(task: task,
commandlineString: commandlineRunnableString,
stdout: stdoutData as Data,
stderr: stderrData as Data))
Thread.doOnMainQueue {
notificationCenter.removeObserver(stdoutObserver)
notificationCenter.removeObserver(stderrObserver)
assert(self.pendingTasks.contains(task), "terminationHandler called with unexpected task")
self.pendingTasks.remove(task)
}
}
Thread.doOnMainQueue {
self.pendingTasks.insert(task)
}
return task
}
// MARK: - TaskOutputReader
// Provides a thread/runloop that may be used to read NSTask output pipes.
private class TaskOutputReader: NSObject {
lazy var thread: Thread = { [unowned self] in
let value = Thread(target: self, selector: #selector(threadMain(_:)), object: nil)
value.name = "com.google.Tulsi.TaskOutputReader"
return value
}()
private var continueRunning = false
func start() {
assert(!thread.isExecuting, "Start called twice without a stop")
thread.start()
}
func stop() {
perform(#selector(TaskOutputReader.stopThread),
on:thread,
with:nil,
waitUntilDone: false)
}
// MARK: - Private methods
@objc
private func threadMain(_ object: AnyObject) {
let runLoop = RunLoop.current
// Add a dummy port to prevent the runloop from returning immediately.
runLoop.add(NSMachPort(), forMode: RunLoopMode.defaultRunLoopMode)
while !thread.isCancelled {
runLoop.run(mode: RunLoopMode.defaultRunLoopMode, before: Date.distantFuture)
}
}
@objc
private func stopThread() {
thread.cancel()
}
}
}