Skip to content

Commit 16c12c4

Browse files
committed
Make the communication between the plugin host and the plugin slightly more sophisticated, in preparation for being able to send messages back and forth.
This approach uses `stdout` for JSON information from the plugin and `stderr` for freeform text from the plugin rather than a specialized zero byte denoting the start of the output JSON. We also close `stdin` to avoid having the plugin block forever in case it tries to read from `stdin` (console input to the plugin isn't available). No change in functionality except that trying to read from `stdin` from the plugin no longer blocks it.
1 parent 85f6ad0 commit 16c12c4

File tree

2 files changed

+104
-48
lines changed

2 files changed

+104
-48
lines changed

Sources/PackagePlugin/Plugin.swift

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,10 @@
2525
// likely change so that it is instead passed on `stdin` of the process that
2626
// runs the plugin, since that avoids any command line length limitations.
2727
//
28-
// Any generated commands and diagnostics are emitted on `stdout` after a zero
29-
// byte; this allows regular output, such as print statements for debugging,
30-
// to be emitted to SwiftPM verbatim. SwiftPM tries to interpret any stdout
31-
// contents after the last zero byte as a JSON encoded output struct in UTF-8
32-
// encoding; any failure to decode it is considered a protocol failure.
28+
// An output structure containing any generated commands and diagnostics is
29+
// passed back to SwiftPM on `stdout`. All freeform output from the plugin
30+
// is redirected to `stderr`, which SwiftPM shows to the user without inter-
31+
// preting it in any way.
3332
//
3433
// The exit code of the compiled plugin determines success or failure (though
3534
// failure to decode the output is also considered a failure to run the ex-
@@ -38,15 +37,34 @@
3837
extension Plugin {
3938

4039
public static func main(_ arguments: [String]) throws {
40+
41+
// Use the initial `stdout` for returning JSON, and redirect `stdout`
42+
// to `stderr` for capturing freeform text.
43+
let jsonOut = fdopen(dup(fileno(stdout)), "w")
44+
dup2(fileno(stderr), fileno(stdout))
45+
46+
// Close `stdin` to avoid blocking if the plugin tries to read input.
47+
close(fileno(stdin))
48+
49+
// Private function for reporting internal errors and halting execution.
50+
func internalError(_ message: String) -> Never {
51+
Diagnostics.error("Internal Error: \(message)")
52+
fputs("Internal Error: \(message)", stderr)
53+
exit(1)
54+
}
55+
4156
// Look for the input JSON as the last argument of the invocation.
4257
guard let inputData = ProcessInfo.processInfo.arguments.last?.data(using: .utf8) else {
43-
fputs("Expected last argument to contain JSON input data in UTF-8 encoding, but didn't find it.", stderr)
44-
Diagnostics.error("Expected last argument to contain JSON input data in UTF-8 encoding, but didn't find it.")
45-
exit(1)
58+
internalError("Expected last argument to contain JSON input data in UTF-8 encoding, but didn't find it.")
4659
}
47-
60+
4861
// Deserialize the input JSON.
49-
let input = try PluginInput(from: inputData)
62+
let input: PluginInput
63+
do {
64+
input = try PluginInput(from: inputData)
65+
} catch {
66+
internalError("Couldn’t decode input JSON: \(error).")
67+
}
5068

5169
// Construct a PluginContext from the deserialized input.
5270
let context = PluginContext(
@@ -57,12 +75,13 @@ extension Plugin {
5775

5876
// Instantiate the plugin. For now there are no parameters, but this is
5977
// where we would set them up, most likely as properties of the plugin
60-
// instance (in a manner similar to SwiftArgumentParser).
78+
// instance (in a manner similar to SwiftArgumentParser). This would
79+
// use property wrappers to mark up properties in the plugin.
6180
let plugin = self.init()
6281

6382
// Invoke the appropriate protocol method, based on the plugin action
6483
// that SwiftPM specified.
65-
let commands: [Command]
84+
let generatedCommands: [Command]
6685
switch input.pluginAction {
6786

6887
case .createBuildToolCommands(let target):
@@ -73,7 +92,7 @@ extension Plugin {
7392
}
7493

7594
// Ask the plugin to create build commands for the input target.
76-
commands = try plugin.createBuildCommands(context: context, target: target)
95+
generatedCommands = try plugin.createBuildCommands(context: context, target: target)
7796

7897
case .performCommand(let targets, let arguments):
7998
// Check that the plugin implements the appropriate protocol for its
@@ -87,15 +106,21 @@ extension Plugin {
87106

88107
// For command plugin there are currently no return commands (any
89108
// commands invoked by the plugin are invoked directly).
90-
commands = []
109+
generatedCommands = []
91110
}
92111

93-
// Construct the output structure to send to SwiftPM.
94-
let output = try PluginOutput(commands: commands, diagnostics: Diagnostics.emittedDiagnostics)
112+
// Construct the output structure to send back to SwiftPM.
113+
let output: PluginOutput
114+
do {
115+
output = try PluginOutput(commands: generatedCommands, diagnostics: Diagnostics.emittedDiagnostics)
116+
} catch {
117+
internalError("Couldn’t encode output JSON: \(error).")
118+
}
95119

96120
// On stdout, write a zero byte followed by the JSON data — this is what libSwiftPM expects to see. Anything before the last zero byte is treated as freeform output from the plugin (such as debug output from `print` statements). Since `FileHandle.write()` doesn't obey buffering we first have to flush any existing output.
97-
fputc(0, stdout)
98-
fwrite([UInt8](output.outputData), 1, output.outputData.count, stdout)
121+
if fwrite([UInt8](output.outputData), 1, output.outputData.count, jsonOut) != output.outputData.count {
122+
internalError("Couldn’t write output JSON: \(strerror(errno).map{ String(cString: $0) } ?? String(describing: errno)).")
123+
}
99124
}
100125

101126
public static func main() throws {

Sources/Workspace/DefaultPluginScriptRunner.swift

Lines changed: 61 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -182,54 +182,85 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
182182
}
183183

184184
fileprivate func invoke(compiledExec: AbsolutePath, pluginArguments: [String], toolsVersion: ToolsVersion, writableDirectories: [AbsolutePath], input: Data) throws -> (outputJSON: Data, stdoutText: Data) {
185-
// Construct the command line.
186-
187-
// FIXME: Need to pass down the arguments and not ignore them.
188-
189-
// FIXME: It would be more robust to pass it as `stdin` data, but we need TSC support for that. When this is
190-
// changed, PackagePlugin will need to change as well (but no plugins need to change).
191-
var command = [compiledExec.pathString]
185+
// Construct the command line. We just pass along any arguments intended for the plugin.
186+
var command = [compiledExec.pathString] + pluginArguments
192187
command += [String(decoding: input, as: UTF8.self)]
193188

194189
// If enabled, run command in a sandbox.
195190
// This provides some safety against arbitrary code execution when invoking the plugin.
196191
// We only allow the permissions which are absolutely necessary.
197192
if self.enableSandbox {
198-
command = Sandbox.apply(command: command, writableDirectories: writableDirectories)
193+
command = Sandbox.apply(command: command, writableDirectories: writableDirectories + [self.cacheDir])
194+
}
195+
196+
// Create and configure a Process.
197+
let process = Process()
198+
process.launchPath = command.first!
199+
process.arguments = Array(command.dropFirst())
200+
process.environment = ProcessInfo.processInfo.environment
201+
process.currentDirectoryURL = self.cacheDir.asURL
202+
203+
// Create a dispatch group for waiting until the process has terminated and the data has been read.
204+
let waiters = DispatchGroup()
205+
// Set up for capturing stdout data.
206+
let stdoutPipe = Pipe()
207+
var stdoutData = Data()
208+
waiters.enter()
209+
stdoutPipe.fileHandleForReading.readabilityHandler = { (fileHandle: FileHandle) -> Void in
210+
let newData = fileHandle.availableData
211+
if newData.isEmpty {
212+
fileHandle.readabilityHandler = nil
213+
waiters.leave()
214+
}
215+
else {
216+
stdoutData.append(contentsOf: newData)
217+
}
218+
}
219+
process.standardOutput = stdoutPipe
220+
221+
// Set up for capturing stderr data.
222+
waiters.enter()
223+
let stderrPipe = Pipe()
224+
var stderrData = Data()
225+
stderrPipe.fileHandleForReading.readabilityHandler = { (fileHandle: FileHandle) -> Void in
226+
let newData = fileHandle.availableData
227+
if newData.isEmpty {
228+
fileHandle.readabilityHandler = nil
229+
waiters.leave()
230+
}
231+
else {
232+
stderrData.append(contentsOf: newData)
233+
}
234+
}
235+
process.standardError = stderrPipe
236+
237+
// Set up a termination handler.
238+
process.terminationHandler = { _ in
239+
waiters.leave()
199240
}
200241

201-
// Invoke the plugin script as a subprocess.
202-
let result: ProcessResult
242+
// Start the process.
243+
waiters.enter()
203244
do {
204-
result = try Process.popen(arguments: command)
245+
try process.run()
205246
} catch {
206247
throw DefaultPluginScriptRunnerError.subprocessDidNotStart("\(error)", command: command)
207248
}
208249

209-
// Collect the output. The `PackagePlugin` runtime library writes the output as a zero byte followed by
210-
// the JSON-serialized PluginEvaluationResult. Since this appears after any free-form output from the
211-
// script, it can be safely split out while maintaining the ability to see debug output without resorting
212-
// to side-channel communication that might be not be very cross-platform (e.g. pipes, file handles, etc).
213-
// We end up with an optional Data for the JSON, and two Datas for stdout and stderr respectively.
214-
var stdoutPieces = (try? result.output.get().split(separator: 0, omittingEmptySubsequences: false)) ?? []
215-
let jsonData = (stdoutPieces.count > 1) ? Data(stdoutPieces.removeLast()) : nil
216-
let stdoutData = Data(stdoutPieces.joined())
217-
let stderrData = (try? Data(result.stderrOutput.get())) ?? Data()
250+
// Wait for the process to terminate and the readers to finish collecting all output.
251+
waiters.wait()
218252

219-
// Throw an error if we the subprocess ended badly.
220-
if result.exitStatus != .terminated(code: 0) {
221-
let output = String(decoding: stdoutData + stderrData, as: UTF8.self)
222-
throw DefaultPluginScriptRunnerError.subprocessFailed("\(result.exitStatus)", command: command, output: output)
223-
}
253+
254+
// Now `stdoutData` contains a JSON-encoded output structure, and `stderrData` contains any free text output from the plugin process.
255+
let stderrText = String(decoding: stderrData, as: UTF8.self)
224256

225-
// Throw an error if we didn't get the JSON data.
226-
guard let json = jsonData else {
227-
let output = String(decoding: stdoutData + stderrData, as: UTF8.self)
228-
throw DefaultPluginScriptRunnerError.missingPluginJSON("didn't receive JSON output data", command: command, output: output)
257+
// Throw an error if we the subprocess ended badly.
258+
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
259+
throw DefaultPluginScriptRunnerError.subprocessFailed("\(process.terminationStatus)", command: command, output: stderrText)
229260
}
230261

231262
// Otherwise return the JSON data and any output text.
232-
return (outputJSON: json, stdoutText: stdoutData + stderrData)
263+
return (outputJSON: stdoutData, stdoutText: stderrData)
233264
}
234265
}
235266

0 commit comments

Comments
 (0)