Skip to content

Commit ff5715e

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 ff5715e

File tree

2 files changed

+107
-48
lines changed

2 files changed

+107
-48
lines changed

Sources/PackagePlugin/Plugin.swift

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
*/
1616

1717
@_implementationOnly import Foundation
18+
#if os(Windows)
19+
@_implementationOnly import ucrt // for stdio functions
20+
#endif
1821

1922
// The way in which SwiftPM communicates with the plugin is an implementation
2023
// detail, but the way it currently works is that the plugin is compiled (in
@@ -25,11 +28,10 @@
2528
// likely change so that it is instead passed on `stdin` of the process that
2629
// runs the plugin, since that avoids any command line length limitations.
2730
//
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.
31+
// An output structure containing any generated commands and diagnostics is
32+
// passed back to SwiftPM on `stdout`. All freeform output from the plugin
33+
// is redirected to `stderr`, which SwiftPM shows to the user without inter-
34+
// preting it in any way.
3335
//
3436
// The exit code of the compiled plugin determines success or failure (though
3537
// failure to decode the output is also considered a failure to run the ex-
@@ -38,15 +40,34 @@
3840
extension Plugin {
3941

4042
public static func main(_ arguments: [String]) throws {
43+
44+
// Use the initial `stdout` for returning JSON, and redirect `stdout`
45+
// to `stderr` for capturing freeform text.
46+
let jsonOut = fdopen(dup(fileno(stdout)), "w")
47+
dup2(fileno(stderr), fileno(stdout))
48+
49+
// Close `stdin` to avoid blocking if the plugin tries to read input.
50+
close(fileno(stdin))
51+
52+
// Private function for reporting internal errors and halting execution.
53+
func internalError(_ message: String) -> Never {
54+
Diagnostics.error("Internal Error: \(message)")
55+
fputs("Internal Error: \(message)", stderr)
56+
exit(1)
57+
}
58+
4159
// Look for the input JSON as the last argument of the invocation.
4260
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)
61+
internalError("Expected last argument to contain JSON input data in UTF-8 encoding, but didn't find it.")
4662
}
47-
63+
4864
// Deserialize the input JSON.
49-
let input = try PluginInput(from: inputData)
65+
let input: PluginInput
66+
do {
67+
input = try PluginInput(from: inputData)
68+
} catch {
69+
internalError("Couldn’t decode input JSON: \(error).")
70+
}
5071

5172
// Construct a PluginContext from the deserialized input.
5273
let context = PluginContext(
@@ -57,12 +78,13 @@ extension Plugin {
5778

5879
// Instantiate the plugin. For now there are no parameters, but this is
5980
// where we would set them up, most likely as properties of the plugin
60-
// instance (in a manner similar to SwiftArgumentParser).
81+
// instance (in a manner similar to SwiftArgumentParser). This would
82+
// use property wrappers to mark up properties in the plugin.
6183
let plugin = self.init()
6284

6385
// Invoke the appropriate protocol method, based on the plugin action
6486
// that SwiftPM specified.
65-
let commands: [Command]
87+
let generatedCommands: [Command]
6688
switch input.pluginAction {
6789

6890
case .createBuildToolCommands(let target):
@@ -73,7 +95,7 @@ extension Plugin {
7395
}
7496

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

78100
case .performCommand(let targets, let arguments):
79101
// Check that the plugin implements the appropriate protocol for its
@@ -87,15 +109,21 @@ extension Plugin {
87109

88110
// For command plugin there are currently no return commands (any
89111
// commands invoked by the plugin are invoked directly).
90-
commands = []
112+
generatedCommands = []
91113
}
92114

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

96123
// 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)
124+
if fwrite([UInt8](output.outputData), 1, output.outputData.count, jsonOut) != output.outputData.count {
125+
internalError("Couldn’t write output JSON: \(strerror(errno).map{ String(cString: $0) } ?? String(describing: errno)).")
126+
}
99127
}
100128

101129
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)