// Author: Simon-Pierre Boucher — contact@spboucher.ai // // Actor wrapping Foundation.Process: launch, stream stdout/stderr line by // line (partial lines buffered, never lost), signal, await exit. All // consumers get lines through AsyncStream — nothing ever touches the main // thread. import Foundation actor ProcessRunner { struct Exit: Sendable { let code: Int32 let wasSignaled: Bool } enum RunnerError: LocalizedError { case notRunning case launchFailed(String) var errorDescription: String? { switch self { case .notRunning: return "Le processus n'est pas en cours d'exécution." case .launchFailed(let why): return "Échec du lancement : \(why)" } } } private var process: Process? var pid: Int32? { process?.processIdentifier } var isRunning: Bool { process?.isRunning ?? false } /// Launches `executable args`, returning a line stream (stdout+stderr /// merged, tagged) and a task that resolves with the exit status. func launch(executable: URL, arguments: [String], currentDirectory: URL? = nil, environment: [String: String]? = nil) throws -> (lines: AsyncStream<(line: String, isStderr: Bool)>, exit: Task) { let p = Process() p.executableURL = executable p.arguments = arguments if let cwd = currentDirectory { p.currentDirectoryURL = cwd } if let env = environment { p.environment = ProcessInfo.processInfo.environment.merging(env) { $1 } } let outPipe = Pipe(), errPipe = Pipe() p.standardOutput = outPipe p.standardError = errPipe var continuation: AsyncStream<(line: String, isStderr: Bool)>.Continuation! let stream = AsyncStream<(line: String, isStderr: Bool)> { continuation = $0 } let cont = continuation! // Two readers feeding one stream; a small actor-free state via // DispatchQueue keeps partial-line buffers private per pipe. let group = DispatchGroup() for (pipe, isErr) in [(outPipe, false), (errPipe, true)] { group.enter() let handle = pipe.fileHandleForReading DispatchQueue.global(qos: .userInitiated).async { var buffer = Data() while true { let chunk = handle.availableData if chunk.isEmpty { break } // EOF buffer.append(chunk) while let nl = buffer.firstIndex(of: 0x0A) { let lineData = buffer[buffer.startIndex.. { await withCheckedContinuation { (k: CheckedContinuation) in group.notify(queue: .global()) { k.resume() } } p.waitUntilExit() cont.finish() return Exit(code: p.terminationStatus, wasSignaled: p.terminationReason == .uncaughtSignal) } return (stream, exitTask) } /// SIGTERM (forge has no SIGINT handler — see RESEARCH.md §5: the /// process dies without a final checkpoint; recovery = ckpt_latest.bin). func terminate() throws { guard let p = process, p.isRunning else { throw RunnerError.notRunning } p.terminate() } /// Escalation of last resort; the caller confirms with the user first. func kill() throws { guard let p = process, p.isRunning else { throw RunnerError.notRunning } Darwin.kill(p.processIdentifier, SIGKILL) } }