PadXcode-Daemon/Daemon/PreBuildRunner.swift

84 lines
3.9 KiB
Swift
Raw Normal View History

2026-04-12 00:42:51 -07:00
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
2026-04-12 22:14:13 -07:00
// 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()
2026-04-12 10:16:21 -07:00
final class TimedOutBox { var value = false }
let timedOutBox = TimedOutBox()
let wd = DispatchWorkItem { timedOutBox.value = true; p.terminate()
2026-04-12 22:14:13 -07:00
session.broadcast(BuildLogMessage(type: .stderr,
text: "⏱ Hook '\(hook.name)' timed out.", exitCode: nil))
2026-04-12 00:42:51 -07:00
}
DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(hook.timeoutSeconds), execute: wd)
2026-04-12 22:14:13 -07:00
2026-04-12 00:42:51 -07:00
let g = DispatchGroup()
g.enter()
out.fileHandleForReading.readabilityHandler = { h in
let d = h.availableData
if d.isEmpty { out.fileHandleForReading.readabilityHandler = nil; g.leave(); return }
2026-04-12 22:14:13 -07:00
if let t = String(data: d, encoding: .utf8) {
session.broadcast(BuildLogMessage(type: .stdout, text: t, exitCode: nil))
}
2026-04-12 00:42:51 -07:00
}
g.enter()
err.fileHandleForReading.readabilityHandler = { h in
let d = h.availableData
if d.isEmpty { err.fileHandleForReading.readabilityHandler = nil; g.leave(); return }
2026-04-12 22:14:13 -07:00
if let t = String(data: d, encoding: .utf8) {
session.broadcast(BuildLogMessage(type: .stderr, text: t, exitCode: nil))
}
2026-04-12 00:42:51 -07:00
}
p.terminationHandler = { proc in
wd.cancel()
2026-04-12 22:14:13 -07:00
g.notify(queue: .global()) {
guard_.resume(continuation: cont,
returning: proc.terminationStatus == 0 && !timedOutBox.value)
}
2026-04-12 00:42:51 -07:00
}
2026-04-12 22:14:13 -07:00
do { try p.run() } catch {
2026-04-12 00:42:51 -07:00
wd.cancel()
2026-04-12 22:14:13 -07:00
session.broadcast(BuildLogMessage(type: .stderr,
text: "Launch failed: \(error)", exitCode: nil))
guard_.resume(continuation: cont, returning: false)
2026-04-12 00:42:51 -07:00
}
}
}
}