`swift run` from inside a command plugin: Bad idea or terrible idea?

So let's say I have a bunch of tasks that are better suited to the abilities/permissiveness of a command plugin, but I do run them every time I build.

What (other than the process environment issues which will cause build tools not to cache their work fixed in 5.10) is the harm in creating a command plugin that does them all for me and then... calls swift run on its way out? That way build plugins get to stay all cozy and in safe in their sandboxes while I still get to do what needs to be done before every build.

This is geared towards packages. And it works. It works both if called via a shell or directly by the process. But it has to be called with swift package --disable-sandbox plugin doit I don't know how to run command plugins via Xcode with their sanboxes off.

Is there a better way to tell the package system "And go ahead and start a build on your way out" than this? Would there be away to do it with the sandbox still enabled so it would work for folks who want to use it via Xcode?

import PackagePlugin
import Foundation

@main
struct ShellCommand: CommandPlugin {
    // Entry point for command plugins applied to Swift Packages.
    func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws {
        
         let packageDirectory = context.package.directory
        let command = "cd \(packageDirectory); ls; swift run"
        let result = try shellOneShot(command)
        
//        let packageDirectoryURL = URL(fileURLWithPath: packageDirectory.string)
//        
//        let result = try runIt(packageDirectoryURL)
        print(result)
    }
}



enum CommandError: Error {
  case unknownError(exitCode: Int32)
}

@discardableResult
func runIt(_ url:URL) throws -> String {
    let task = Process()
    let pipe = Pipe()
    
    task.standardOutput = pipe
    task.standardError = pipe
    task.arguments = ["run"]
    
    task.currentDirectoryURL = url
    //task.qualityOfService
    //task.environment
    
    task.standardInput = nil
    task.executableURL = URL(fileURLWithPath: "/usr/bin/swift")
    try task.run()
    
    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: .utf8)!
    
    task.waitUntilExit()

    if task.terminationStatus == 0 || task.terminationStatus == 2 {
      return output
    } else {
      print(output)
      throw CommandError.unknownError(exitCode: task.terminationStatus)
    }
}


//MARK: Shell Caller
@discardableResult
func shellOneShot(_ command: String) throws -> String {
    let task = Process()
    let pipe = Pipe()
    
    task.standardOutput = pipe
    task.standardError = pipe
    task.arguments = ["-c", command]
    
    //task.currentDirectoryURL
    //task.qualityOfService
    //task.environment
    
    task.standardInput = nil
    task.executableURL = URL(fileURLWithPath: "/bin/zsh")
    try task.run()
    
    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: .utf8)!
    
    task.waitUntilExit()

    if task.terminationStatus == 0 || task.terminationStatus == 2 {
      return output
    } else {
      print(output)
      throw CommandError.unknownError(exitCode: task.terminationStatus)
    }
    
    
}

It's not a good idea (probably a terrible idea if you're looking for stable and working builds), as multiple swift run and swift build invocations will try to access a build database (and other resources) for this package at the same time. In the best case scenario, one of those will stop with an error due to a failure to acquire a database lock. In a worse scenario, one of the processes (I'd bet the inner swift run from the plugin) will deadlock the whole thing, waiting for the lock to be released, not progressing and thus not letting the outer swift build to release the lock.

Either of those hopefully will dissuade you from doing it. But the worst would be if it sometimes works and you start relying on that intermittent behaviour, until some day it stops working in some critical CI job or another important workflow.

1 Like

Thank you for replying. I am dissuaded.

I've come to actually appreciate that build plugins have sort of promised the build system that the source folder won't change out from under it, but I'm too lazy not to be low key irritated by having to run the command plugin by hand first.

I guess this is a pitch for something part of PackagePlugin that could more safely be the "start a build on exit" addendum to a command plugin. Or a outside-but-immediately-proceeding-the-build-process-command-plugin. The nomenclature would be hard at this point with "prebuild plugin" being used for "non-caching build plugin that runs early, but inside the build process envelope", if I have understood correctly.

Low priority since the needed tasks can be handled. I'm just lazy. Thanks again.

I don't know what your requirements are, but maybe it's worth noting that there's a fully supported way for a SwiftPM command plugin to initiate a build for a specific product or target (or all products and targets), and that's packageManager.build(_:parameters:).

You can't invoke swift run, though.

1 Like

Indeed I did not.

And here it is being used inside a plugin no less

And if it works, just run the product it directly from the .build folder to not retrigger the build system.

Thank you. I'll update with code when (if) I get it to work.

Just want to give heads up that things seem to be rebuilt unconditionally, see this issue:

Thanks for the heads up. Super interesting concern, separate build settings for each executable (project vs. tool). It doens't impact me. Debug for the plugins is perfect for me since I want them at their chattiest.

Althoug for you maybe a custom command plugin script with with packageManager.build calls per target is a way forward there in the short term too? since the first parameter is what subset? Not sure it all since I JUST learned about it, but I'll add that to the list of things to test and include it in the code.

1 Like

Okay, note to future folks, this code will get you unspecified("internalError(\"unimplemented\")") in Xcode 15.2 because Xcode hasn't implemented the proxy?

However, using swift package plugin doit works. (doit being the very creative command plugin verb in this case)


    func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws {

        print("Did I make it to here?")
        
        var parameters = PackageManager.BuildParameters()
        //parameters.logging = .verbose //(Well not THAT chatty.)
        
        print(parameters)
        
        let result = try packageManager.build(.all(includingTests: false), parameters: parameters)
        print("How about here?")


        print(result)
    }

As does:

        let targets = context.package.targets
        for target in targets {
            print("trying for target... \(target.name)")
            let result = try packageManager.build(.target(target.name), parameters: parameters)
            print(result)
        }

There are lots of examples:

https://github.com/search?q="packageManager.build"+language%3ASwift&type=code

The built code then runs fine with:

./.build/{hardware}/debug/DemoFruitStore fruit_list citrus

But then that can become

        for target in targets {
            print("trying for target... \(target.name)")
            let result = try packageManager.build(.target(target.name), parameters: parameters)
            if result.succeeded {
                let runnableArtifacts = result.builtArtifacts.filter({$0.kind == .executable })
                if let executable = runnableArtifacts.first {
                     //runProcess included below. Its just like runIt and shellOneShot
                    let message = try runProcess(URL(fileURLWithPath: executable.path.string), arguments: ["fruit_list", "citrus"])
                    print(message)
                }
            }
            print(result.builtArtifacts)
        }

And I have a good start on my one command-plugin pre-gamed build&run!

THANK YOU @ole !

For @hassila I think doing something along the lines of parameters.configuration = target.name.contains("PluginTool") ? .release : .debug might be a place to start, but honestly I would want to switch over to a 5.10 branch before trying, which is on my list of things to do in the next few weeks. If I swing back around I'll let you know!


For completeness:

@discardableResult
func runProcess(_ url:URL, arguments:[String] = []) throws -> String {
    let task = Process()
    let pipe = Pipe()
    
    task.standardOutput = pipe
    task.standardError = pipe
    task.arguments = arguments

    task.standardInput = nil
    task.executableURL = url
    try task.run()
    
    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: .utf8)!
    
    task.waitUntilExit()

    if task.terminationStatus == 0 || task.terminationStatus == 2 {
      return output
    } else {
      print(output)
      throw CommandError.unknownError(exitCode: task.terminationStatus)
    }
}
1 Like