83 lines
3.9 KiB
Swift
83 lines
3.9 KiB
Swift
import Foundation
|
||
import Vapor
|
||
|
||
final class PreBuildRunner {
|
||
static func run(hooks: [PreBuildHook], session: BuildSession, logger: Logger) async -> Bool {
|
||
for hook in hooks {
|
||
session.broadcast(BuildLogMessage(type: .stdout, text: "\n⚙️ Hook: \(hook.name)\n", exitCode: nil))
|
||
let ok = await runHook(hook, session: session)
|
||
if !ok {
|
||
session.broadcast(BuildLogMessage(type: .stderr, text: "❌ Hook '\(hook.name)' failed.", exitCode: nil))
|
||
if !hook.continueOnFailure { return false }
|
||
} else {
|
||
session.broadcast(BuildLogMessage(type: .stdout, text: "✅ \(hook.name) done.\n", exitCode: nil))
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
private static func runHook(_ hook: PreBuildHook, session: BuildSession) async -> Bool {
|
||
await withCheckedContinuation { cont in
|
||
let p = Process()
|
||
p.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||
p.arguments = ["-l", "-c", hook.command]
|
||
p.currentDirectoryURL = URL(fileURLWithPath: hook.workingDirectory)
|
||
var env = ProcessInfo.processInfo.environment
|
||
env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:" + (env["PATH"] ?? "")
|
||
p.environment = env
|
||
let out = Pipe(); let err = Pipe()
|
||
p.standardOutput = out; p.standardError = err
|
||
|
||
// Guard against double-resume: if p.run() throws, the catch block resumes first,
|
||
// then empty-data pipe handlers fire → g.leave()×2 → g.notify → second resume.
|
||
final class ResumeOnce {
|
||
private let lock = NSLock(); private var done = false
|
||
func resume(continuation: CheckedContinuation<Bool, Never>, returning value: Bool) {
|
||
lock.lock(); defer { lock.unlock() }
|
||
guard !done else { return }; done = true
|
||
continuation.resume(returning: value)
|
||
}
|
||
}
|
||
let guard_ = ResumeOnce()
|
||
|
||
final class TimedOutBox { var value = false }
|
||
let timedOutBox = TimedOutBox()
|
||
let wd = DispatchWorkItem { timedOutBox.value = true; p.terminate()
|
||
session.broadcast(BuildLogMessage(type: .stderr,
|
||
text: "⏱ Hook '\(hook.name)' timed out.", exitCode: nil))
|
||
}
|
||
DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(hook.timeoutSeconds), execute: wd)
|
||
|
||
let g = DispatchGroup()
|
||
g.enter()
|
||
out.fileHandleForReading.readabilityHandler = { h in
|
||
let d = h.availableData
|
||
if d.isEmpty { out.fileHandleForReading.readabilityHandler = nil; g.leave(); return }
|
||
if let t = String(data: d, encoding: .utf8) {
|
||
session.broadcast(BuildLogMessage(type: .stdout, text: t, exitCode: nil))
|
||
}
|
||
}
|
||
g.enter()
|
||
err.fileHandleForReading.readabilityHandler = { h in
|
||
let d = h.availableData
|
||
if d.isEmpty { err.fileHandleForReading.readabilityHandler = nil; g.leave(); return }
|
||
if let t = String(data: d, encoding: .utf8) {
|
||
session.broadcast(BuildLogMessage(type: .stderr, text: t, exitCode: nil))
|
||
}
|
||
}
|
||
p.terminationHandler = { proc in
|
||
wd.cancel()
|
||
g.notify(queue: .global()) {
|
||
guard_.resume(continuation: cont,
|
||
returning: proc.terminationStatus == 0 && !timedOutBox.value)
|
||
}
|
||
}
|
||
do { try p.run() } catch {
|
||
wd.cancel()
|
||
session.broadcast(BuildLogMessage(type: .stderr,
|
||
text: "Launch failed: \(error)", exitCode: nil))
|
||
guard_.resume(continuation: cont, returning: false)
|
||
}
|
||
}
|
||
}
|
||
}
|