Skip to content

Commit deba117

Browse files
committed
Reimplement launchBash() based on TSCBasic.Process, which is much more advanced than Foundation's version at this time.
1 parent 6571403 commit deba117

File tree

4 files changed

+156
-138
lines changed

4 files changed

+156
-138
lines changed

Package.resolved

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,17 @@ let package = Package(
1717
dependencies: [
1818
.package(url: "https://github.com/SwiftPackageIndex/ShellQuote", from: "1.0.2"),
1919
.package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"),
20+
.package(url: "https://github.com/apple/swift-tools-support-core.git", from: "0.5.2"),
21+
.package(url: "https://github.com/apple/swift-algorithms.git", from: "1.0.0"),
2022
],
2123
targets: [
2224
.target(
2325
name: "ShellOut",
2426
dependencies: [
2527
.product(name: "ShellQuote", package: "ShellQuote"),
2628
.product(name: "Logging", package: "swift-log"),
29+
.product(name: "Algorithms", package: "swift-algorithms"),
30+
.product(name: "TSCBasic", package: "swift-tools-support-core"),
2731
],
2832
path: "Sources"
2933
),

Sources/ShellOut.swift

Lines changed: 54 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import Foundation
88
import Dispatch
99
import Logging
10+
import TSCBasic
11+
import Algorithms
1012

1113
// MARK: - API
1214

@@ -33,30 +35,31 @@ import Logging
3335
to command: SafeString,
3436
arguments: [Argument] = [],
3537
at path: String = ".",
36-
process: Process = .init(),
3738
logger: Logger? = nil,
3839
outputHandle: FileHandle? = nil,
3940
errorHandle: FileHandle? = nil,
40-
environment: [String : String]? = nil,
41-
eofTimeout: DispatchTimeInterval = .milliseconds(10)
42-
) throws -> (stdout: String, stderr: String) {
43-
let command = "cd \(path.escapingSpaces) && \(command) \(arguments.map(\.string).joined(separator: " "))"
41+
environment: [String : String]? = nil
42+
) async throws -> (stdout: String, stderr: String) {
43+
let command = "\(command) \(arguments.map(\.string).joined(separator: " "))"
4444

45-
return try process.launchBash(
45+
return try await TSCBasic.Process.launchBash(
4646
with: command,
4747
logger: logger,
4848
outputHandle: outputHandle,
4949
errorHandle: errorHandle,
5050
environment: environment,
51-
eofTimeout: eofTimeout
51+
at: path == "." ? nil :
52+
(path == "~" ? TSCBasic.localFileSystem.homeDirectory.pathString :
53+
(path.starts(with: "~/") ? "\(TSCBasic.localFileSystem.homeDirectory.pathString)/\(path.dropFirst(2))" :
54+
path))
5255
)
5356
}
5457

5558
@discardableResult public func shellOutOldVersion(
5659
to command: SafeString,
5760
arguments: [Argument] = [],
5861
at path: String = ".",
59-
process: Process = .init(),
62+
process: Foundation.Process = .init(),
6063
outputHandle: FileHandle? = nil,
6164
errorHandle: FileHandle? = nil,
6265
environment: [String : String]? = nil
@@ -92,30 +95,26 @@ import Logging
9295
@discardableResult public func shellOut(
9396
to command: ShellOutCommand,
9497
at path: String = ".",
95-
process: Process = .init(),
9698
logger: Logger? = nil,
9799
outputHandle: FileHandle? = nil,
98100
errorHandle: FileHandle? = nil,
99-
environment: [String : String]? = nil,
100-
eofTimeout: DispatchTimeInterval = .milliseconds(10)
101-
) throws -> (stdout: String, stderr: String) {
102-
try shellOut(
101+
environment: [String : String]? = nil
102+
) async throws -> (stdout: String, stderr: String) {
103+
try await shellOut(
103104
to: command.command,
104105
arguments: command.arguments,
105106
at: path,
106-
process: process,
107107
logger: logger,
108108
outputHandle: outputHandle,
109109
errorHandle: errorHandle,
110-
environment: environment,
111-
eofTimeout: eofTimeout
110+
environment: environment
112111
)
113112
}
114113

115114
@discardableResult public func shellOutOldVersion(
116115
to command: ShellOutCommand,
117116
at path: String = ".",
118-
process: Process = .init(),
117+
process: Foundation.Process = .init(),
119118
outputHandle: FileHandle? = nil,
120119
errorHandle: FileHandle? = nil,
121120
environment: [String : String]? = nil
@@ -437,91 +436,53 @@ extension ShellOutCommand {
437436

438437
// MARK: - Private
439438

440-
private extension Process {
441-
@discardableResult func launchBash(
439+
private extension TSCBasic.Process {
440+
@discardableResult static func launchBash(
442441
with command: String,
443442
logger: Logger? = nil,
444443
outputHandle: FileHandle? = nil,
445444
errorHandle: FileHandle? = nil,
446445
environment: [String : String]? = nil,
447-
eofTimeout: DispatchTimeInterval = .milliseconds(10)
448-
) throws -> (stdout: String, stderr: String) {
449-
self.executableURL = URL(fileURLWithPath: "/bin/bash")
450-
self.arguments = ["-c", command]
451-
452-
if let environment {
453-
self.environment = environment
454-
}
455-
456-
let outputPipe = Pipe(), errorPipe = Pipe()
457-
self.standardOutput = outputPipe
458-
self.standardError = errorPipe
459-
460-
// Because FileHandle's readabilityHandler might be called from a
461-
// different queue from the calling queue, avoid data races by
462-
// protecting reads and writes to outputData and errorData on
463-
// a single dispatch queue.
464-
let outputQueue = DispatchQueue(label: "bash-output-queue")
465-
let outputGroup = DispatchGroup()
466-
var outputData = Data(), errorData = Data()
467-
468-
outputGroup.enter()
469-
outputPipe.fileHandleForReading.readabilityHandler = { handler in
470-
let data = handler.availableData
471-
472-
if data.isEmpty { // EOF
473-
handler.readabilityHandler = nil
474-
outputGroup.leave()
475-
} else {
476-
outputQueue.async {
477-
outputData.append(data)
478-
outputHandle?.write(data)
479-
}
446+
at: String? = nil
447+
) async throws -> (stdout: String, stderr: String) {
448+
let process = try Self.init(
449+
arguments: ["/bin/bash", "-c", command],
450+
environment: environment ?? ProcessEnv.vars,
451+
workingDirectory: at.map { try .init(validating: $0) } ?? TSCBasic.localFileSystem.currentWorkingDirectory ?? .root,
452+
outputRedirection: .collect(redirectStderr: false),
453+
startNewProcessGroup: false,
454+
loggingHandler: nil
455+
)
456+
457+
try process.launch()
458+
459+
let result = try await process.waitUntilExit()
460+
461+
try outputHandle?.write(contentsOf: (try? result.output.get()) ?? [])
462+
try outputHandle?.close()
463+
try errorHandle?.write(contentsOf: (try? result.stderrOutput.get()) ?? [])
464+
try errorHandle?.close()
465+
466+
guard case .terminated(code: let code) = result.exitStatus, code == 0 else {
467+
let code: Int32
468+
switch result.exitStatus {
469+
case .terminated(code: let termCode): code = termCode
470+
case .signalled(signal: let sigNo): code = -sigNo
480471
}
472+
throw ShellOutError(
473+
terminationStatus: code,
474+
errorData: Data((try? result.stderrOutput.get()) ?? []),
475+
outputData: Data((try? result.output.get()) ?? [])
476+
)
481477
}
482-
483-
outputGroup.enter()
484-
errorPipe.fileHandleForReading.readabilityHandler = { handler in
485-
let data = handler.availableData
486-
487-
if data.isEmpty { // EOF
488-
handler.readabilityHandler = nil
489-
outputGroup.leave()
490-
} else {
491-
outputQueue.async {
492-
errorData.append(data)
493-
errorHandle?.write(data)
494-
}
495-
}
496-
}
497-
498-
try self.run()
499-
self.waitUntilExit()
500-
501-
if outputGroup.wait(timeout: .now() + eofTimeout) == .timedOut {
502-
logger?.debug("ShellOut.launchBash: Timed out waiting for EOF! (command: \(command))")
503-
}
504-
505-
// We know as of this point that either all blocks have been submitted to the
506-
// queue already, or we've reached our wait timeout.
507-
return try outputQueue.sync {
508-
// Do not try to readToEnd() here; if we already got an EOF, there's definitely
509-
// nothing to read, and if we timed out, trying to read here will just block
510-
// even longer.
511-
try outputHandle?.close()
512-
try errorHandle?.close()
513-
514-
guard self.terminationStatus == 0, self.terminationReason == .exit else {
515-
throw ShellOutError(
516-
terminationStatus: terminationStatus,
517-
errorData: errorData,
518-
outputData: outputData
519-
)
520-
}
521-
return (stdout: outputData.shellOutput(), stderr: errorData.shellOutput())
522-
}
478+
return try (
479+
stdout: String(result.utf8Output().trimmingSuffix(while: \.isNewline)),
480+
stderr: String(result.utf8stderrOutput().trimmingSuffix(while: \.isNewline))
481+
)
523482
}
483+
}
524484

485+
extension Foundation.Process {
525486
@discardableResult func launchBashOldVersion(with command: String, outputHandle: FileHandle? = nil, errorHandle: FileHandle? = nil, environment: [String : String]? = nil) throws -> (stdout: String, stderr: String) {
526487
#if os(Linux)
527488
executableURL = URL(fileURLWithPath: "/bin/bash")

0 commit comments

Comments
 (0)