spb/forge-studio Public
The Instruments of LLM training — a native macOS cockpit for Forge. Train language models from scratch on Apple Silicon without a terminal.
Swift 95.7%
Shell 4.3%
1// Author: Simon-Pierre Boucher — contact@spboucher.ai2//3// Actor wrapping Foundation.Process: launch, stream stdout/stderr line by4// line (partial lines buffered, never lost), signal, await exit. All5// consumers get lines through AsyncStream — nothing ever touches the main6// thread.7import Foundation89actor ProcessRunner {10 struct Exit: Sendable {11 let code: Int3212 let wasSignaled: Bool13 }1415 enum RunnerError: LocalizedError {16 case notRunning17 case launchFailed(String)18 var errorDescription: String? {19 switch self {20 case .notRunning: return "Le processus n'est pas en cours d'exécution."21 case .launchFailed(let why): return "Échec du lancement : \(why)"22 }23 }24 }2526 private var process: Process?2728 var pid: Int32? { process?.processIdentifier }29 var isRunning: Bool { process?.isRunning ?? false }3031 /// Launches `executable args`, returning a line stream (stdout+stderr32 /// merged, tagged) and a task that resolves with the exit status.33 func launch(executable: URL, arguments: [String],34 currentDirectory: URL? = nil,35 environment: [String: String]? = nil)36 throws -> (lines: AsyncStream<(line: String, isStderr: Bool)>,37 exit: Task<Exit, Never>)38 {39 let p = Process()40 p.executableURL = executable41 p.arguments = arguments42 if let cwd = currentDirectory { p.currentDirectoryURL = cwd }43 if let env = environment {44 p.environment = ProcessInfo.processInfo.environment.merging(env) { $1 }45 }46 let outPipe = Pipe(), errPipe = Pipe()47 p.standardOutput = outPipe48 p.standardError = errPipe4950 var continuation: AsyncStream<(line: String, isStderr: Bool)>.Continuation!51 let stream = AsyncStream<(line: String, isStderr: Bool)> { continuation = $0 }52 let cont = continuation!5354 // Two readers feeding one stream; a small actor-free state via55 // DispatchQueue keeps partial-line buffers private per pipe.56 let group = DispatchGroup()57 for (pipe, isErr) in [(outPipe, false), (errPipe, true)] {58 group.enter()59 let handle = pipe.fileHandleForReading60 DispatchQueue.global(qos: .userInitiated).async {61 var buffer = Data()62 while true {63 let chunk = handle.availableData64 if chunk.isEmpty { break } // EOF65 buffer.append(chunk)66 while let nl = buffer.firstIndex(of: 0x0A) {67 let lineData = buffer[buffer.startIndex..<nl]68 buffer.removeSubrange(buffer.startIndex...nl)69 if let line = String(data: lineData, encoding: .utf8) {70 cont.yield((line, isErr))71 }72 }73 }74 if !buffer.isEmpty, let tail = String(data: buffer, encoding: .utf8) {75 cont.yield((tail, isErr)) // final unterminated line76 }77 group.leave()78 }79 }8081 do { try p.run() } catch {82 cont.finish()83 throw RunnerError.launchFailed(error.localizedDescription)84 }85 process = p8687 let exitTask = Task<Exit, Never> {88 await withCheckedContinuation { (k: CheckedContinuation<Void, Never>) in89 group.notify(queue: .global()) { k.resume() }90 }91 p.waitUntilExit()92 cont.finish()93 return Exit(code: p.terminationStatus,94 wasSignaled: p.terminationReason == .uncaughtSignal)95 }96 return (stream, exitTask)97 }9899 /// SIGTERM (forge has no SIGINT handler — see RESEARCH.md §5: the100 /// process dies without a final checkpoint; recovery = ckpt_latest.bin).101 func terminate() throws {102 guard let p = process, p.isRunning else { throw RunnerError.notRunning }103 p.terminate()104 }105106 /// Escalation of last resort; the caller confirms with the user first.107 func kill() throws {108 guard let p = process, p.isRunning else { throw RunnerError.notRunning }109 Darwin.kill(p.processIdentifier, SIGKILL)110 }111}112