Skip to content

Commit 43d0ca6

Browse files
authored
Errors thrown at top level of plugin should be reported as diagnostics (#3957)
When a plugin throws an uncaught error, it should be reported as a diagnostic rather than causing the standard Swift output. As part of making this work well, we also simplify the way the result code is received from the plugin. It was a bit awkward to have two result codes (the one from the last message as well as the exit code from the process) so now we just use the exit code. This is also most robust to any plugin that decides to do "exit(1)" on its own from inside the code. If/when we have long-running multi-action plugins we can revisit this. We also make sure that at least one error message is emitted from any plugin that exits with an error code but that does not emit an error diagnostic. This also reworks the unit test for command invocations to be more scalable.
1 parent a78e3f3 commit 43d0ca6

File tree

3 files changed

+177
-73
lines changed

3 files changed

+177
-73
lines changed

Sources/PackagePlugin/Plugin.swift

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,15 @@ extension Plugin {
9292
// Handle messages from the host until the input stream is closed,
9393
// indicating that we're done.
9494
while let message = try pluginHostConnection.waitForNextMessage() {
95-
try handleMessage(message)
95+
do {
96+
try handleMessage(message)
97+
}
98+
catch {
99+
// Emit a diagnostic and indicate failure to the plugin host,
100+
// and exit with an error code.
101+
Diagnostics.error(String(describing: error))
102+
exit(1)
103+
}
96104
}
97105
}
98106

@@ -185,8 +193,8 @@ extension Plugin {
185193
try plugin.performCommand(context: context, targets: targets, arguments: arguments)
186194
}
187195

188-
// Send back a message to the host indicating that we're done.
189-
try pluginHostConnection.sendMessage(.actionComplete(success: true))
196+
// Exit with a zero exit code to indicate success.
197+
exit(0)
190198

191199
default:
192200
internalError("unexpected top-level message \(message)")
@@ -258,9 +266,6 @@ enum PluginToHostMessage: Encodable {
258266

259267
/// The plugin is requesting symbol graph information for a given target and set of options.
260268
case symbolGraphRequest(targetName: String, options: PackageManager.SymbolGraphOptions)
261-
262-
/// The plugin has finished the requested action.
263-
case actionComplete(success: Bool)
264269
}
265270

266271
typealias PluginHostConnection = MessageConnection<PluginToHostMessage, HostToPluginMessage>

Sources/Workspace/DefaultPluginScriptRunner.swift

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
375375
process.standardInput = stdinPipe
376376

377377
// Private message handler method. Always invoked on the callback queue.
378-
var result: Bool? = .none
378+
var emittedAtLeastOneError = false
379379
func handle(message: PluginToHostMessage) throws {
380380
dispatchPrecondition(condition: .onQueue(callbackQueue))
381381
switch message {
@@ -390,6 +390,7 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
390390
let diagnostic: Basics.Diagnostic
391391
switch severity {
392392
case .error:
393+
emittedAtLeastOneError = true
393394
diagnostic = .error(message, metadata: metadata)
394395
case .warning:
395396
diagnostic = .warning(message, metadata: metadata)
@@ -447,13 +448,6 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
447448
outputQueue.async { try? outputHandle.writePluginMessage(.errorResponse(error: String(describing: error))) }
448449
}
449450
}
450-
451-
case .actionComplete(let success):
452-
// The plugin has indicated that it's finished the requested action.
453-
result = success
454-
outputQueue.async {
455-
try? outputHandle.close()
456-
}
457451
}
458452
}
459453

@@ -463,7 +457,7 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
463457
stdoutPipe.fileHandleForReading.readabilityHandler = { fileHandle in
464458
// Parse the next message and pass it on to the delegate.
465459
stdoutLock.withLock {
466-
if let message = try? fileHandle.readPluginMessage() {
460+
while let message = try? fileHandle.readPluginMessage() {
467461
// FIXME: We should handle errors here.
468462
callbackQueue.async { try? handle(message: message) }
469463
}
@@ -488,31 +482,35 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
488482

489483
// Set up a handler to deal with the exit of the plugin process.
490484
process.terminationHandler = { process in
485+
// Close the output handle through which we talked to the plugin.
486+
try? outputHandle.close()
487+
488+
// Read and pass on any remaining messages from the plugin.
489+
stdoutPipe.fileHandleForReading.readabilityHandler?(stdoutPipe.fileHandleForReading)
490+
491491
// Read and pass on any remaining free-form text output from the plugin.
492492
stderrPipe.fileHandleForReading.readabilityHandler?(stderrPipe.fileHandleForReading)
493-
493+
494494
// Call the completion block with a result that depends on how the process ended.
495495
callbackQueue.async {
496496
completion(Result {
497+
// We throw an error if the plugin ended with a signal.
497498
if process.terminationReason == .uncaughtSignal {
498499
throw DefaultPluginScriptRunnerError.invocationEndedBySignal(
499500
signal: process.terminationStatus,
500501
command: command,
501502
output: String(decoding: stderrData, as: UTF8.self))
502503
}
503-
if process.terminationStatus != 0 {
504-
throw DefaultPluginScriptRunnerError.invocationEndedWithNonZeroExitCode(
505-
exitCode: process.terminationStatus,
506-
command: command,
507-
output: String(decoding: stderrData, as: UTF8.self))
504+
// Otherwise we return a result based on its exit code. If
505+
// the plugin exits with an error but hasn't already emitted
506+
// an error, we do so for it.
507+
let success = (process.terminationStatus == 0)
508+
if !success && !emittedAtLeastOneError {
509+
delegate.pluginEmittedDiagnostic(
510+
.error("Plugin ended with exit code \(process.terminationStatus)")
511+
)
508512
}
509-
guard let result = result else {
510-
throw DefaultPluginScriptRunnerError.pluginCommunicationError(
511-
message: "didn’t receive output result from plugin",
512-
command: command,
513-
output: String(decoding: stderrData, as: UTF8.self))
514-
}
515-
return result
513+
return success
516514
})
517515
}
518516
}
@@ -660,9 +658,6 @@ enum PluginToHostMessage: Decodable {
660658

661659
/// The plugin is requesting symbol graph information for a given target and set of options.
662660
case symbolGraphRequest(targetName: String, options: PluginInvocationSymbolGraphOptions)
663-
664-
/// The plugin has finished the requested action.
665-
case actionComplete(success: Bool)
666661
}
667662

668663
fileprivate extension FileHandle {

Tests/FunctionalTests/PluginTests.swift

Lines changed: 146 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ class PluginTests: XCTestCase {
187187
}
188188

189189
func testCommandPluginInvocation() throws {
190+
// FIXME: This test is getting quite long — we should add some support functionality for creating synthetic plugin tests and factor this out into separate tests.
190191
try testWithTemporaryDirectory { tmpPath in
191192
// Create a sample package with a library target and a plugin. It depends on a sample package.
192193
let packageDir = tmpPath.appending(components: "MyPackage")
@@ -207,10 +208,22 @@ class PluginTests: XCTestCase {
207208
]
208209
),
209210
.plugin(
210-
name: "MyPlugin",
211+
name: "PluginPrintingInfo",
211212
capability: .command(
212-
intent: .custom(verb: "mycmd", description: "What is mycmd anyway?"),
213-
permissions: [.writeToPackageDirectory(reason: "YOLO")]
213+
intent: .custom(verb: "print-info", description: "Description of the command"),
214+
permissions: [.writeToPackageDirectory(reason: "Reason for wanting to write to package directory")]
215+
)
216+
),
217+
.plugin(
218+
name: "PluginFailingWithError",
219+
capability: .command(
220+
intent: .custom(verb: "fail-with-error", description: "Sample plugin that throws an error")
221+
)
222+
),
223+
.plugin(
224+
name: "PluginFailingWithoutError",
225+
capability: .command(
226+
intent: .custom(verb: "fail-without-error", description: "Sample plugin that exits without error")
214227
)
215228
),
216229
]
@@ -222,7 +235,7 @@ class PluginTests: XCTestCase {
222235
public func Foo() { }
223236
"""
224237
}
225-
try localFileSystem.writeFileContents(packageDir.appending(components: "Plugins", "MyPlugin", "plugin.swift")) {
238+
try localFileSystem.writeFileContents(packageDir.appending(components: "Plugins", "PluginPrintingInfo", "plugin.swift")) {
226239
$0 <<< """
227240
import PackagePlugin
228241
@@ -243,6 +256,49 @@ class PluginTests: XCTestCase {
243256
}
244257
"""
245258
}
259+
try localFileSystem.writeFileContents(packageDir.appending(components: "Plugins", "PluginFailingWithError", "plugin.swift")) {
260+
$0 <<< """
261+
import PackagePlugin
262+
263+
@main
264+
struct MyCommandPlugin: CommandPlugin {
265+
func performCommand(
266+
context: PluginContext,
267+
targets: [Target],
268+
arguments: [String]
269+
) throws {
270+
// Print some output that should appear before the error diagnostic.
271+
print("This text should appear before the error.")
272+
273+
// Throw an uncaught error that should be reported as a diagnostics.
274+
throw "Houston, we have a problem."
275+
}
276+
}
277+
extension String: Error { }
278+
"""
279+
}
280+
try localFileSystem.writeFileContents(packageDir.appending(components: "Plugins", "PluginFailingWithoutError", "plugin.swift")) {
281+
$0 <<< """
282+
import PackagePlugin
283+
import Foundation
284+
285+
@main
286+
struct MyCommandPlugin: CommandPlugin {
287+
func performCommand(
288+
context: PluginContext,
289+
targets: [Target],
290+
arguments: [String]
291+
) throws {
292+
// Print some output that should appear before we exit.
293+
print("This text should appear before we exit.")
294+
295+
// Just exit with an error code without an emitting error.
296+
exit(1)
297+
}
298+
}
299+
extension String: Error { }
300+
"""
301+
}
246302

247303
// Create the sample vendored dependency package.
248304
try localFileSystem.writeFileContents(packageDir.appending(components: "VendoredDependencies", "HelperPackage", "Package.swift")) {
@@ -302,62 +358,110 @@ class PluginTests: XCTestCase {
302358
let libraryTarget = try XCTUnwrap(package.targets.map(\.underlyingTarget).first{ $0.name == "MyLibrary" } as? SwiftTarget)
303359
XCTAssertEqual(libraryTarget.type, .library)
304360

305-
// Find the command plugin in our test package.
306-
let pluginTarget = try XCTUnwrap(package.targets.map(\.underlyingTarget).first{ $0.name == "MyPlugin" } as? PluginTarget)
307-
XCTAssertEqual(pluginTarget.type, .plugin)
308-
309361
// Set up a delegate to handle callbacks from the command plugin.
310362
let delegateQueue = DispatchQueue(label: "plugin-invocation")
311363
class PluginDelegate: PluginInvocationDelegate {
312364
let delegateQueue: DispatchQueue
313-
var outputData = Data()
365+
var diagnostics: [Basics.Diagnostic] = []
314366

315367
init(delegateQueue: DispatchQueue) {
316368
self.delegateQueue = delegateQueue
317369
}
318370

319371
func pluginEmittedOutput(_ data: Data) {
372+
// Add each line of emitted output as a `.info` diagnostic.
320373
dispatchPrecondition(condition: .onQueue(delegateQueue))
321-
outputData.append(contentsOf: data)
322-
print(String(decoding: data, as: UTF8.self)
323-
.split(separator: "\n")
324-
.map{ "🧩 \($0)" }
325-
.joined(separator: "\n")
326-
)
374+
let textlines = String(decoding: data, as: UTF8.self).split(separator: "\n")
375+
print(textlines.map{ "🧩 \($0)" }.joined(separator: "\n"))
376+
diagnostics.append(contentsOf: textlines.map{
377+
Basics.Diagnostic(severity: .info, message: String($0), metadata: .none)
378+
})
327379
}
328380

329381
func pluginEmittedDiagnostic(_ diagnostic: Basics.Diagnostic) {
382+
// Add the diagnostic as-is.
330383
dispatchPrecondition(condition: .onQueue(delegateQueue))
384+
diagnostics.append(diagnostic)
331385
}
332386
}
333-
let pluginDelegate = PluginDelegate(delegateQueue: delegateQueue)
334387

335-
// Invoke the command plugin.
336-
let pluginCacheDir = tmpPath.appending(component: "plugin-cache")
337-
let pluginOutputDir = tmpPath.appending(component: "plugin-output")
338-
let pluginScriptRunner = DefaultPluginScriptRunner(cacheDir: pluginCacheDir, toolchain: ToolchainConfiguration.default)
339-
let target = try XCTUnwrap(package.targets.first{ $0.underlyingTarget == libraryTarget })
340-
let invocationSucceeded = try tsc_await { pluginTarget.invoke(
341-
action: .performCommand(
342-
targets: [ target ],
343-
arguments: ["veni", "vidi", "vici"]),
344-
package: package,
345-
buildEnvironment: BuildEnvironment(platform: .macOS, configuration: .debug),
346-
scriptRunner: pluginScriptRunner,
347-
outputDirectory: pluginOutputDir,
348-
toolSearchDirectories: [UserToolchain.default.swiftCompilerPath.parentDirectory],
349-
toolNamesToPaths: [:],
350-
fileSystem: localFileSystem,
351-
observabilityScope: observability.topScope,
352-
callbackQueue: delegateQueue,
353-
delegate: pluginDelegate,
354-
completion: $0) }
355-
356-
// Check the results.
357-
XCTAssertTrue(invocationSucceeded)
358-
let outputText = String(decoding: pluginDelegate.outputData, as: UTF8.self)
359-
XCTAssertTrue(outputText.contains("Root package is MyPackage."), outputText)
360-
XCTAssertTrue(outputText.contains("Found the swiftc tool"), outputText)
388+
// Helper function to invoke a plugin with given input and to check its outputs.
389+
func testCommand(
390+
package: ResolvedPackage,
391+
plugin pluginName: String,
392+
targets targetNames: [String],
393+
arguments: [String],
394+
toolSearchDirectories: [AbsolutePath] = [UserToolchain.default.swiftCompilerPath.parentDirectory],
395+
toolNamesToPaths: [String: AbsolutePath] = [:],
396+
file: StaticString = #file,
397+
line: UInt = #line,
398+
expectFailure: Bool = false,
399+
diagnosticsChecker: (DiagnosticsTestResult) throws -> Void
400+
) {
401+
// Find the named plugin.
402+
let plugins = package.targets.compactMap{ $0.underlyingTarget as? PluginTarget }
403+
guard let plugin = plugins.first(where: { $0.name == pluginName }) else {
404+
return XCTFail("There is no plugin target named ‘\(pluginName)")
405+
}
406+
XCTAssertTrue(plugin.type == .plugin, "Target \(plugin) isn’t a plugin")
407+
408+
// Find the named input targets to the plugin.
409+
var targets: [ResolvedTarget] = []
410+
for targetName in targetNames {
411+
guard let target = package.targets.first(where: { $0.underlyingTarget.name == targetName }) else {
412+
return XCTFail("There is no target named ‘\(targetName)")
413+
}
414+
XCTAssertTrue(target.type != .plugin, "Target \(target) is a plugin")
415+
targets.append(target)
416+
}
417+
418+
let pluginDir = tmpPath.appending(components: package.identity.description, plugin.name)
419+
let scriptRunner = DefaultPluginScriptRunner(cacheDir: pluginDir.appending(component: "cache"), toolchain: ToolchainConfiguration.default)
420+
let delegate = PluginDelegate(delegateQueue: delegateQueue)
421+
do {
422+
let success = try tsc_await { plugin.invoke(
423+
action: .performCommand(targets: targets, arguments: arguments),
424+
package: package,
425+
buildEnvironment: BuildEnvironment(platform: .macOS, configuration: .debug),
426+
scriptRunner: scriptRunner,
427+
outputDirectory: pluginDir.appending(component: "output"),
428+
toolSearchDirectories: [UserToolchain.default.swiftCompilerPath.parentDirectory],
429+
toolNamesToPaths: [:],
430+
fileSystem: localFileSystem,
431+
observabilityScope: observability.topScope,
432+
callbackQueue: delegateQueue,
433+
delegate: delegate,
434+
completion: $0) }
435+
if expectFailure {
436+
XCTAssertFalse(success, "expected command to fail, but it succeeded", file: file, line: line)
437+
}
438+
else {
439+
XCTAssertTrue(success, "expected command to succeed, but it failed", file: file, line: line)
440+
}
441+
}
442+
catch {
443+
XCTFail("error \(String(describing: error))", file: file, line: line)
444+
}
445+
testDiagnostics(delegate.diagnostics, problemsOnly: false, file: file, line: line, handler: diagnosticsChecker)
446+
}
447+
448+
// Invoke the command plugin that prints out various things it was given, and check them.
449+
testCommand(package: package, plugin: "PluginPrintingInfo", targets: ["MyLibrary"], arguments: ["veni", "vidi", "vici"]) { output in
450+
output.check(diagnostic: .equal("Root package is MyPackage."), severity: .info)
451+
output.check(diagnostic: .and(.prefix("Found the swiftc tool"), .suffix(".")), severity: .info)
452+
}
453+
454+
// Invoke the command plugin that throws an unhandled error at the top level.
455+
testCommand(package: package, plugin: "PluginFailingWithError", targets: [], arguments: [], expectFailure: true) { output in
456+
output.check(diagnostic: .equal("This text should appear before the error."), severity: .info)
457+
output.check(diagnostic: .equal("Houston, we have a problem."), severity: .error)
458+
459+
}
460+
// Invoke the command plugin that exits with code 1 without returning an error.
461+
testCommand(package: package, plugin: "PluginFailingWithoutError", targets: [], arguments: [], expectFailure: true) { output in
462+
output.check(diagnostic: .equal("This text should appear before we exit."), severity: .info)
463+
output.check(diagnostic: .equal("Plugin ended with exit code 1"), severity: .error)
464+
}
361465
}
362466
}
363467
}

0 commit comments

Comments
 (0)