Skip to content

Commit bcd3c3b

Browse files
committed
Clean up the communication between the plugin host and plugin to use FileHandle when possible and make the operations more clear. Also send the input on stdin instead of as an argument to avoid platform-dependent limits on argument buffer length, and to allow more natural passing of command plugin arguments.
1 parent 2856ac6 commit bcd3c3b

File tree

2 files changed

+107
-60
lines changed

2 files changed

+107
-60
lines changed

Sources/PackagePlugin/Plugin.swift

Lines changed: 78 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -16,65 +16,103 @@
1616

1717
@_implementationOnly import Foundation
1818
#if os(Windows)
19-
@_implementationOnly import ucrt // for stdio functions
19+
@_implementationOnly import ucrt
2020
#endif
2121

22-
// The way in which SwiftPM communicates with the plugin is an implementation
23-
// detail, but the way it currently works is that the plugin is compiled (in
24-
// a very similar way to the package manifest) and then run in a sandbox.
22+
// The specifics of how SwiftPM communicates with the plugin are implementation
23+
// details, but the way it currently works is that the plugin is compiled as an
24+
// executable and then run in a sandbox that blocks network access and prevents
25+
// changes to all except a few file system locations.
2526
//
26-
// Currently the plugin input is provided in the form of a JSON-encoded input
27-
// structure passed as the last command line argument; however, this will very
28-
// likely change so that it is instead passed on `stdin` of the process that
29-
// runs the plugin, since that avoids any command line length limitations.
27+
// The "plugin host" (SwiftPM or an IDE using libSwiftPM) sends a JSON-encoded
28+
// context struct to the plugin process on its original standard-input pipe, and
29+
///when finished, the plugin sends a JSON-encoded result struct back to the host
30+
// on its original standard-output pipe. The plugin host treats output on the
31+
// standard-error pipe as free-form output text from the plugin (for debugging
32+
// purposes, etc).
33+
34+
// Within the plugin process, `stdout` is redirected to `stderr` so that print
35+
// statements from the plugin are treated as plain-text output, and `stdin` is
36+
// closed so that attemps by the plugin logic to read from console input return
37+
// errors instead of blocking. The original `stdin` and `stdout` are duplicated
38+
// for use as messaging pipes, and are not directly used by the plugin logic.
3039
//
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.
40+
// Using the standard input and output streams avoids having to make allowances
41+
// in the sandbox for other channels of communication, and seems a more portable
42+
// approach than many of the alternatives.
3543
//
36-
// The exit code of the compiled plugin determines success or failure (though
37-
// failure to decode the output is also considered a failure to run the ex-
38-
// tension).
44+
// The exit code of the plugin process determines whether the plugin invocation
45+
// is considered successful. A failure result should also be accompanied by an
46+
// emitted error diagnostic, so that errors are understandable by the user.
3947

4048
extension Plugin {
4149

4250
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.
51+
// Private function to report internal errors and then exit.
5352
func internalError(_ message: String) -> Never {
5453
Diagnostics.error("Internal Error: \(message)")
5554
fputs("Internal Error: \(message)", stderr)
5655
exit(1)
5756
}
5857

59-
// Look for the input JSON as the last argument of the invocation.
60-
guard let inputData = ProcessInfo.processInfo.arguments.last?.data(using: .utf8) else {
61-
internalError("Expected last argument to contain JSON input data in UTF-8 encoding, but didn't find it.")
58+
// Private function to construct an error message from an `errno` code.
59+
func describe(errno: Int32) -> String {
60+
if let cStr = strerror(errno) { return String(cString: cStr) }
61+
return String(describing: errno)
6262
}
6363

64-
// Deserialize the input JSON.
65-
let input: PluginInput
64+
// Duplicate the `stdin` file descriptor, which we will then use as an
65+
// input stream from which we receive messages from the plugin host.
66+
let inputFD = dup(fileno(stdin))
67+
guard inputFD >= 0 else {
68+
internalError("Could not duplicate `stdin`: \(describe(errno: errno)).")
69+
}
70+
71+
// Having duplicated the original standard-input descriptor, we close
72+
// `stdin` so that attempts by the plugin to read console input (which
73+
// are usually a mistake) return errors instead of blocking.
74+
guard close(fileno(stdin)) >= 0 else {
75+
internalError("Could not close `stdin`: \(describe(errno: errno)).")
76+
}
77+
78+
// Duplicate the `stdout` file descriptor, which we will then use as a
79+
// message stream to which we send output to the plugin host.
80+
let outputFD = dup(fileno(stdout))
81+
guard outputFD >= 0 else {
82+
internalError("Could not dup `stdout`: \(describe(errno: errno)).")
83+
}
84+
85+
// Having duplicated the original standard-output descriptor, redirect
86+
// `stdout` to `stderr` so that all free-form text output goes there.
87+
guard dup2(fileno(stderr), fileno(stdout)) >= 0 else {
88+
internalError("Could not dup2 `stdout` to `stderr`: \(describe(errno: errno)).")
89+
}
90+
91+
// Turn off full buffering so printed text appears as soon as possible.
92+
setlinebuf(stdout)
93+
94+
// Open input and output handles for read from and writing to the host.
95+
let inputHandle = FileHandle(fileDescriptor: inputFD)
96+
let outputHandle = FileHandle(fileDescriptor: outputFD)
97+
98+
// Read the input data (a JSON-encoded struct) from the host. It has
99+
// all the input context for the plugin invocation.
100+
guard let inputData = try inputHandle.readToEnd() else {
101+
internalError("Couldn’t read input JSON.")
102+
}
103+
let inputStruct: PluginInput
66104
do {
67-
input = try PluginInput(from: inputData)
105+
inputStruct = try PluginInput(from: inputData)
68106
} catch {
69107
internalError("Couldn’t decode input JSON: \(error).")
70108
}
71109

72110
// Construct a PluginContext from the deserialized input.
73111
let context = PluginContext(
74-
package: input.package,
75-
pluginWorkDirectory: input.pluginWorkDirectory,
76-
builtProductsDirectory: input.builtProductsDirectory,
77-
toolNamesToPaths: input.toolNamesToPaths)
112+
package: inputStruct.package,
113+
pluginWorkDirectory: inputStruct.pluginWorkDirectory,
114+
builtProductsDirectory: inputStruct.builtProductsDirectory,
115+
toolNamesToPaths: inputStruct.toolNamesToPaths)
78116

79117
// Instantiate the plugin. For now there are no parameters, but this is
80118
// where we would set them up, most likely as properties of the plugin
@@ -85,7 +123,7 @@ extension Plugin {
85123
// Invoke the appropriate protocol method, based on the plugin action
86124
// that SwiftPM specified.
87125
let generatedCommands: [Command]
88-
switch input.pluginAction {
126+
switch inputStruct.pluginAction {
89127

90128
case .createBuildToolCommands(let target):
91129
// Check that the plugin implements the appropriate protocol for its
@@ -112,18 +150,14 @@ extension Plugin {
112150
generatedCommands = []
113151
}
114152

115-
// Construct the output structure to send back to SwiftPM.
116-
let output: PluginOutput
153+
// Send back the output data (a JSON-encoded struct) to the plugin host.
154+
let outputStruct: PluginOutput
117155
do {
118-
output = try PluginOutput(commands: generatedCommands, diagnostics: Diagnostics.emittedDiagnostics)
156+
outputStruct = try PluginOutput(commands: generatedCommands, diagnostics: Diagnostics.emittedDiagnostics)
119157
} catch {
120158
internalError("Couldn’t encode output JSON: \(error).")
121159
}
122-
123-
// 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.
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-
}
160+
try outputHandle.write(contentsOf: outputStruct.outputData)
127161
}
128162

129163
public static func main() throws {

Sources/Workspace/DefaultPluginScriptRunner.swift

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
4343
guard let compiledExecutable = result.compiledExecutable else {
4444
throw DefaultPluginScriptRunnerError.compilationFailed(result)
4545
}
46-
return try self.invoke(compiledExec: compiledExecutable, pluginArguments: pluginArguments, toolsVersion: toolsVersion, writableDirectories: writableDirectories, input: inputJSON)
46+
return try self.invoke(compiledExec: compiledExecutable, pluginArguments: pluginArguments, writableDirectories: writableDirectories, inputData: inputJSON)
4747
}
4848

4949
public var hostTriple: Triple {
@@ -180,29 +180,33 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
180180
guard let versionString = try runResult.utf8Output().components(separatedBy: "\n").first(where: { $0.contains("minos") })?.components(separatedBy: " ").last else { return nil }
181181
return PlatformVersion(versionString)
182182
}
183-
184-
fileprivate func invoke(compiledExec: AbsolutePath, pluginArguments: [String], toolsVersion: ToolsVersion, writableDirectories: [AbsolutePath], input: Data) throws -> (outputJSON: Data, stdoutText: Data) {
185-
// Construct the command line. We just pass along any arguments intended for the plugin.
183+
184+
/// Private function that invokes a compiled plugin executable with a particular set of arguments and JSON-encoded input data.
185+
fileprivate func invoke(
186+
compiledExec: AbsolutePath,
187+
pluginArguments: [String],
188+
writableDirectories: [AbsolutePath],
189+
inputData: Data
190+
) throws -> (outputJSON: Data, stdoutText: Data) {
191+
// Construct the command line. We just pass along any arguments intended for the plugin.
186192
var command = [compiledExec.pathString] + pluginArguments
187-
command += [String(decoding: input, as: UTF8.self)]
188193

189-
// If enabled, run command in a sandbox.
190-
// This provides some safety against arbitrary code execution when invoking the plugin.
191-
// We only allow the permissions which are absolutely necessary.
194+
// Optionally wrap the command in a sandbox, which places some limits on what it can do. In particular, it blocks network access and restricts the paths to which the plugin can make file system changes.
192195
if self.enableSandbox {
193196
command = Sandbox.apply(command: command, writableDirectories: writableDirectories + [self.cacheDir])
194197
}
195198

196-
// Create and configure a Process.
199+
// Create and configure a Process. We set the working directory to the cache directory, so that relative paths end up there.
197200
let process = Process()
198-
process.launchPath = command.first!
201+
process.executableURL = Foundation.URL(fileURLWithPath: command[0])
199202
process.arguments = Array(command.dropFirst())
200203
process.environment = ProcessInfo.processInfo.environment
201204
process.currentDirectoryURL = self.cacheDir.asURL
202-
203-
// Create a dispatch group for waiting until the process has terminated and the data has been read.
205+
206+
// Create a dispatch group for waiting until on the process as well as all output from it.
204207
let waiters = DispatchGroup()
205-
// Set up for capturing stdout data.
208+
209+
// Set up a pipe for receiving stdout data (the JSON-encoded output results).
206210
let stdoutPipe = Pipe()
207211
var stdoutData = Data()
208212
waiters.enter()
@@ -218,7 +222,7 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
218222
}
219223
process.standardOutput = stdoutPipe
220224

221-
// Set up for capturing stderr data.
225+
// Set up a pipe for receiving stderr data (free-form printed text from the plugin).
222226
waiters.enter()
223227
let stderrPipe = Pipe()
224228
var stderrData = Data()
@@ -233,9 +237,14 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
233237
}
234238
}
235239
process.standardError = stderrPipe
236-
240+
241+
// Set up a pipe for sending stdin data (the JSON-encoded input context).
242+
let stdinPipe = Pipe()
243+
process.standardInput = stdinPipe
244+
237245
// Set up a termination handler.
238246
process.terminationHandler = { _ in
247+
// We don't do anything special other than note the process exit.
239248
waiters.leave()
240249
}
241250

@@ -247,10 +256,14 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
247256
throw DefaultPluginScriptRunnerError.subprocessDidNotStart("\(error)", command: command)
248257
}
249258

259+
// Write the input data to the plugin, and close the stream to tell the plugin we're done.
260+
// TODO: We should do this asynchronously; this is coming up as part of the more flexible communication between host and plugin.
261+
try stdinPipe.fileHandleForWriting.write(contentsOf: inputData)
262+
try stdinPipe.fileHandleForWriting.close()
263+
250264
// Wait for the process to terminate and the readers to finish collecting all output.
251265
waiters.wait()
252266

253-
254267
// Now `stdoutData` contains a JSON-encoded output structure, and `stderrData` contains any free text output from the plugin process.
255268
let stderrText = String(decoding: stderrData, as: UTF8.self)
256269

0 commit comments

Comments
 (0)