SPB Git

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%
4.2 KB · 112 lines swift
Raw Blame History
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