ProcessManager: spawn / stdin / kill / captured / event bus (#2) #34
@@ -0,0 +1,55 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
/// Severity levels, matching the v1 `log_level` setting values.
|
||||||
|
public enum LogLevel: String, Codable, Sendable, CaseIterable {
|
||||||
|
case error, warn, info, debug, trace
|
||||||
|
|
||||||
|
var osType: OSLogType {
|
||||||
|
switch self {
|
||||||
|
case .error: return .error
|
||||||
|
case .warn: return .default
|
||||||
|
case .info: return .info
|
||||||
|
case .debug: return .debug
|
||||||
|
case .trace: return .debug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rank: Int {
|
||||||
|
switch self {
|
||||||
|
case .error: return 0
|
||||||
|
case .warn: return 1
|
||||||
|
case .info: return 2
|
||||||
|
case .debug: return 3
|
||||||
|
case .trace: return 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Central logger. For M1 PR2 this writes to `os.Logger` only;
|
||||||
|
/// issue #5 adds the rolling file sink and runtime `setLevel`.
|
||||||
|
public struct AppLogger: Sendable {
|
||||||
|
public static let shared = AppLogger(category: "app")
|
||||||
|
|
||||||
|
private let osLog: Logger
|
||||||
|
public let category: String
|
||||||
|
|
||||||
|
public init(category: String) {
|
||||||
|
self.category = category
|
||||||
|
self.osLog = Logger(
|
||||||
|
subsystem: AppPaths.bundleIdentifier,
|
||||||
|
category: category
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func log(_ level: LogLevel, _ message: @autoclosure () -> String) {
|
||||||
|
let text = LogSanitizer.sanitize(message())
|
||||||
|
osLog.log(level: level.osType, "\(text, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func error(_ message: @autoclosure () -> String) { log(.error, message()) }
|
||||||
|
public func warn(_ message: @autoclosure () -> String) { log(.warn, message()) }
|
||||||
|
public func info(_ message: @autoclosure () -> String) { log(.info, message()) }
|
||||||
|
public func debug(_ message: @autoclosure () -> String) { log(.debug, message()) }
|
||||||
|
public func trace(_ message: @autoclosure () -> String) { log(.trace, message()) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Rewrites the user's home directory to `~` in log output
|
||||||
|
/// (docs/03 §Logging hygiene — `sanitize_arg_for_logging`).
|
||||||
|
public enum LogSanitizer {
|
||||||
|
/// Replaces every occurrence of the current user's home path with `~`.
|
||||||
|
public static func sanitize(_ text: String) -> String {
|
||||||
|
let home = NSHomeDirectory()
|
||||||
|
guard !home.isEmpty else { return text }
|
||||||
|
return text.replacingOccurrences(of: home, with: "~")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitizes an argv list for display.
|
||||||
|
public static func sanitizeArgs(_ args: [String]) -> String {
|
||||||
|
args.map(sanitize).joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Accumulates stdout lines into a complete JSON document.
|
||||||
|
///
|
||||||
|
/// Several Argyll tools (`instlist`, `profcheck`, `printtarg` manifest)
|
||||||
|
/// emit pretty-printed multi-line JSON on stdout. Individual lines are
|
||||||
|
/// *not* valid JSON — only the whole block is — so callers route stdout
|
||||||
|
/// lines here and get `Data` back once the buffer parses.
|
||||||
|
///
|
||||||
|
/// `ROW_COLORS_JSON: ` lines never reach this type; ProcessManager
|
||||||
|
/// diverts them to `jsonRow` events first.
|
||||||
|
public struct JSONAccumulator: Sendable {
|
||||||
|
private var buffer = Data()
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Appends one stdout line. Returns the complete document bytes when
|
||||||
|
/// the accumulated buffer forms valid JSON, otherwise `nil`.
|
||||||
|
public mutating func feed(line: String) -> Data? {
|
||||||
|
buffer.append(Data(line.utf8))
|
||||||
|
buffer.append(0x0A)
|
||||||
|
return tryParse()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to decode the accumulated buffer; clears it on success.
|
||||||
|
public mutating func decode<T: Decodable>(_ type: T.Type) -> T? {
|
||||||
|
guard let data = tryParse() else { return nil }
|
||||||
|
return try? JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw buffer when it parses, `nil` while still incomplete.
|
||||||
|
public var completeData: Data? {
|
||||||
|
var copy = self
|
||||||
|
return copy.tryParse()
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func reset() {
|
||||||
|
buffer.removeAll(keepingCapacity: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isEmpty: Bool { buffer.isEmpty }
|
||||||
|
|
||||||
|
private mutating func tryParse() -> Data? {
|
||||||
|
// Cheap gate: JSON documents start with { or [.
|
||||||
|
guard let first = buffer.first(where: { !$0.isJSONWhitespace }),
|
||||||
|
first == UInt8(ascii: "{") || first == UInt8(ascii: "[")
|
||||||
|
else { return nil }
|
||||||
|
guard (try? JSONSerialization.jsonObject(with: buffer)) != nil else { return nil }
|
||||||
|
let out = buffer
|
||||||
|
buffer.removeAll(keepingCapacity: false)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension UInt8 {
|
||||||
|
var isJSONWhitespace: Bool {
|
||||||
|
self == 0x20 || self == 0x09 || self == 0x0A || self == 0x0D
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Events on the process bus — the v2 equivalent of the v1 Tauri events
|
||||||
|
/// `process:stdout|stderr|exit|error|json_row` (docs/02 §Event bus).
|
||||||
|
public enum ProcessEvent: Sendable, Equatable {
|
||||||
|
/// Non-JSON stdout line. (`process:stdout`)
|
||||||
|
case stdout(id: String, line: String)
|
||||||
|
/// stderr line. (`process:stderr`)
|
||||||
|
case stderr(id: String, line: String)
|
||||||
|
/// Child exited; 0 = success. (`process:exit`)
|
||||||
|
case exit(id: String, code: Int32)
|
||||||
|
/// Spawn failure. (`process:error`)
|
||||||
|
case error(id: String, message: String)
|
||||||
|
/// Stdout line began with `ROW_COLORS_JSON: ` — prefix stripped,
|
||||||
|
/// payload is the remaining raw bytes. (`process:json_row`)
|
||||||
|
case jsonRow(id: String, payload: Data)
|
||||||
|
|
||||||
|
public var id: String {
|
||||||
|
switch self {
|
||||||
|
case .stdout(let id, _), .stderr(let id, _), .exit(let id, _),
|
||||||
|
.error(let id, _), .jsonRow(let id, _):
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ProcessError: Error, Equatable, Sendable {
|
||||||
|
/// A child with this id is still running (#116).
|
||||||
|
case duplicateID(String)
|
||||||
|
/// No child registered under this id.
|
||||||
|
case unknownID(String)
|
||||||
|
/// Process refused to launch.
|
||||||
|
case spawnFailed(String)
|
||||||
|
/// stdin write failed (pipe closed / process gone).
|
||||||
|
case stdinFailed(String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Deterministic process ids (docs/02 §Event bus). Listeners must always
|
||||||
|
/// filter events on `id` — historical bug #56 was an id mismatch.
|
||||||
|
public enum ProcessID {
|
||||||
|
public static let instlist = "instlist"
|
||||||
|
|
||||||
|
public static func targen(_ basename: String) -> String { "targen_\(basename)" }
|
||||||
|
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
|
||||||
|
public static func chartread(_ basename: String) -> String { "chartread_\(basename)" }
|
||||||
|
public static func average(_ basename: String) -> String { "average_\(basename)" }
|
||||||
|
public static func colprof(_ basename: String) -> String { "colprof_\(basename)" }
|
||||||
|
public static func profcheck(ti3Path: String) -> String { "profcheck_\(ti3Path)" }
|
||||||
|
public static func iccgamut(stem: String) -> String { "iccgamut_\(stem)" }
|
||||||
|
public static func printcal(_ stem: String) -> String { "printcal_\(stem)" }
|
||||||
|
public static func applycal(_ stem: String) -> String { "applycal_\(stem)" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Incremental byte→line decoder for process pipes.
|
||||||
|
///
|
||||||
|
/// Splits raw `availableData` chunks at `0x0A`. A newline byte can never
|
||||||
|
/// appear inside a multi-byte UTF-8 sequence (continuation bytes are
|
||||||
|
/// ≥ 0x80), so splitting bytes at `\n` is always scalar-safe; each line
|
||||||
|
/// is then decoded with a lossy fallback for non-UTF-8 output.
|
||||||
|
public struct ProcessLineDecoder: Sendable {
|
||||||
|
public private(set) var pending = Data()
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Feeds a chunk; returns every complete line found (without `\n`).
|
||||||
|
public mutating func feed(_ chunk: Data) -> [String] {
|
||||||
|
guard !chunk.isEmpty else { return [] }
|
||||||
|
pending.append(chunk)
|
||||||
|
var lines: [String] = []
|
||||||
|
while let nl = pending.firstIndex(of: 0x0A) {
|
||||||
|
var slice = pending.prefix(upTo: nl)
|
||||||
|
pending = pending.suffix(from: pending.index(after: nl))
|
||||||
|
// Tolerate CRLF output.
|
||||||
|
if slice.last == 0x0D { slice = slice.dropLast() }
|
||||||
|
lines.append(Self.decode(slice))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flushes any unterminated remainder at EOF. Returns `nil` when empty.
|
||||||
|
public mutating func finish() -> String? {
|
||||||
|
guard !pending.isEmpty else { return nil }
|
||||||
|
var rest = pending
|
||||||
|
pending.removeAll(keepingCapacity: false)
|
||||||
|
if rest.last == 0x0D { rest = rest.dropLast() }
|
||||||
|
return rest.isEmpty ? nil : Self.decode(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decode(_ bytes: Data.SubSequence) -> String {
|
||||||
|
String(decoding: bytes, as: UTF8.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Captured output from `runCaptured` (used by printcal/applycal —
|
||||||
|
/// the only tools whose results arrive as one-shot output).
|
||||||
|
public struct CapturedResult: Sendable, Equatable {
|
||||||
|
public let stdout: String
|
||||||
|
public let stderr: String
|
||||||
|
public let exitCode: Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn / stdin / kill / event bus for Argyll sidecar children
|
||||||
|
/// (docs/02 §Event bus, docs/03 §Process manager).
|
||||||
|
///
|
||||||
|
/// Invariants:
|
||||||
|
/// - Duplicate `id` while a child runs is rejected (#116).
|
||||||
|
/// - The stdin handle lives in its own map, independent of wait, so
|
||||||
|
/// `sendStdin` never blocks on process exit (#84).
|
||||||
|
/// - `ARGYLL_NOT_INTERACTIVE=1` is set on every child.
|
||||||
|
/// - stdout lines beginning `ROW_COLORS_JSON: ` become `jsonRow` events
|
||||||
|
/// with the prefix stripped; all other stdout is `stdout` events.
|
||||||
|
/// - `exit` is emitted exactly once per child, and only after both
|
||||||
|
/// output pipes reach EOF — so no buffered output is lost on fast
|
||||||
|
/// exits or kills.
|
||||||
|
/// - `kill` drops the stdin handle so writers fail fast.
|
||||||
|
public actor ProcessManager {
|
||||||
|
|
||||||
|
public static let rowColorsPrefix = "ROW_COLORS_JSON: "
|
||||||
|
|
||||||
|
public static let shared = ProcessManager()
|
||||||
|
|
||||||
|
// MARK: - Event bus (multicast)
|
||||||
|
|
||||||
|
private var subscribers: [UUID: AsyncStream<ProcessEvent>.Continuation] = [:]
|
||||||
|
|
||||||
|
/// Subscribe to the event bus. Each call returns an independent
|
||||||
|
/// stream; every event is delivered to every live subscriber.
|
||||||
|
public nonisolated func events() -> AsyncStream<ProcessEvent> {
|
||||||
|
AsyncStream { continuation in
|
||||||
|
let token = UUID()
|
||||||
|
Task { await self.addSubscriber(continuation, token: token) }
|
||||||
|
continuation.onTermination = { _ in
|
||||||
|
Task { await self.removeSubscriber(token) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addSubscriber(
|
||||||
|
_ continuation: AsyncStream<ProcessEvent>.Continuation,
|
||||||
|
token: UUID
|
||||||
|
) {
|
||||||
|
subscribers[token] = continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeSubscriber(_ token: UUID) {
|
||||||
|
subscribers.removeValue(forKey: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emit(_ event: ProcessEvent) {
|
||||||
|
for continuation in subscribers.values {
|
||||||
|
continuation.yield(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Child registry
|
||||||
|
|
||||||
|
private struct RunningChild {
|
||||||
|
let process: Process
|
||||||
|
/// stdin lives in its own slot, independent of process wait (#84).
|
||||||
|
var stdin: FileHandle?
|
||||||
|
var stdoutDecoder: ProcessLineDecoder
|
||||||
|
var stderrDecoder: ProcessLineDecoder
|
||||||
|
var stdoutEOF = false
|
||||||
|
var stderrEOF = false
|
||||||
|
/// Set by the termination handler; `exit` is emitted once both
|
||||||
|
/// pipes have also reached EOF.
|
||||||
|
var pendingExitCode: Int32?
|
||||||
|
var finalized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private var children: [String: RunningChild] = [:]
|
||||||
|
/// Processes owned by `runCaptured` (dup detection + kill support).
|
||||||
|
private var captured: [String: Process] = [:]
|
||||||
|
|
||||||
|
/// Ids of currently-running children.
|
||||||
|
public var runningIDs: [String] { Array(children.keys) + captured.keys }
|
||||||
|
|
||||||
|
public func isRunning(_ id: String) -> Bool {
|
||||||
|
children[id] != nil || captured[id] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spawn (streaming)
|
||||||
|
|
||||||
|
/// Spawns a streaming child. Returns after spawn; callers wait for
|
||||||
|
/// `exit(id:)` events — never assume the return means the tool
|
||||||
|
/// finished (docs/03).
|
||||||
|
public func runStreaming(
|
||||||
|
id: String,
|
||||||
|
binary: URL,
|
||||||
|
arguments: [String],
|
||||||
|
workingDirectory: URL? = nil,
|
||||||
|
environment: [String: String] = [:]
|
||||||
|
) throws {
|
||||||
|
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
let stdinPipe = Pipe()
|
||||||
|
let stdoutPipe = Pipe()
|
||||||
|
let stderrPipe = Pipe()
|
||||||
|
process.executableURL = binary
|
||||||
|
process.arguments = arguments
|
||||||
|
process.currentDirectoryURL = workingDirectory
|
||||||
|
process.standardInput = stdinPipe
|
||||||
|
process.standardOutput = stdoutPipe
|
||||||
|
process.standardError = stderrPipe
|
||||||
|
process.environment = childEnvironment(extra: environment)
|
||||||
|
|
||||||
|
AppLogger(category: "process").debug(
|
||||||
|
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||||
|
)
|
||||||
|
|
||||||
|
children[id] = RunningChild(
|
||||||
|
process: process,
|
||||||
|
stdin: stdinPipe.fileHandleForWriting,
|
||||||
|
stdoutDecoder: ProcessLineDecoder(),
|
||||||
|
stderrDecoder: ProcessLineDecoder()
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
children.removeValue(forKey: id)
|
||||||
|
emit(.error(id: id, message: error.localizedDescription))
|
||||||
|
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdoutHandle = stdoutPipe.fileHandleForReading
|
||||||
|
let stderrHandle = stderrPipe.fileHandleForReading
|
||||||
|
stdoutHandle.readabilityHandler = { [weak self] handle in
|
||||||
|
let data = handle.availableData
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.ingestOutput(data, id: id, isStderr: false, handle: handle) }
|
||||||
|
}
|
||||||
|
stderrHandle.readabilityHandler = { [weak self] handle in
|
||||||
|
let data = handle.availableData
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
|
||||||
|
}
|
||||||
|
|
||||||
|
process.terminationHandler = { [weak self] proc in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spawn (captured)
|
||||||
|
|
||||||
|
/// Runs a child to completion and returns all output. Reads stdout
|
||||||
|
/// and stderr concurrently so a full pipe buffer can never deadlock
|
||||||
|
/// the child. Used by `printcal` / `applycal` (docs/03).
|
||||||
|
public func runCaptured(
|
||||||
|
id: String,
|
||||||
|
binary: URL,
|
||||||
|
arguments: [String],
|
||||||
|
workingDirectory: URL? = nil,
|
||||||
|
environment: [String: String] = [:]
|
||||||
|
) async throws -> CapturedResult {
|
||||||
|
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
let stdoutPipe = Pipe()
|
||||||
|
let stderrPipe = Pipe()
|
||||||
|
process.executableURL = binary
|
||||||
|
process.arguments = arguments
|
||||||
|
process.currentDirectoryURL = workingDirectory
|
||||||
|
process.standardOutput = stdoutPipe
|
||||||
|
process.standardError = stderrPipe
|
||||||
|
process.environment = childEnvironment(extra: environment)
|
||||||
|
|
||||||
|
AppLogger(category: "process").debug(
|
||||||
|
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register before run() so a concurrent duplicate spawn fails.
|
||||||
|
captured[id] = process
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
captured.removeValue(forKey: id)
|
||||||
|
emit(.error(id: id, message: error.localizedDescription))
|
||||||
|
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
async let outData = Task.detached {
|
||||||
|
stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
}.value
|
||||||
|
async let errData = Task.detached {
|
||||||
|
stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
}.value
|
||||||
|
|
||||||
|
let code = await withCheckedContinuation { continuation in
|
||||||
|
process.terminationHandler = { proc in
|
||||||
|
continuation.resume(returning: proc.terminationStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (out, err) = await (outData, errData)
|
||||||
|
// If kill() already reaped this child, its exit event went out.
|
||||||
|
if captured.removeValue(forKey: id) != nil {
|
||||||
|
emit(.exit(id: id, code: code))
|
||||||
|
}
|
||||||
|
|
||||||
|
return CapturedResult(
|
||||||
|
stdout: String(decoding: out, as: UTF8.self),
|
||||||
|
stderr: String(decoding: err, as: UTF8.self),
|
||||||
|
exitCode: code
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - stdin
|
||||||
|
|
||||||
|
/// Writes the exact bytes (caller includes `\n`) to a child's stdin
|
||||||
|
/// and flushes (docs/03 §stdin protocol).
|
||||||
|
public func sendStdin(id: String, bytes: Data) throws {
|
||||||
|
guard let child = children[id] else { throw ProcessError.unknownID(id) }
|
||||||
|
guard let handle = child.stdin else {
|
||||||
|
throw ProcessError.stdinFailed("stdin closed for \(id)")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try handle.write(contentsOf: bytes)
|
||||||
|
} catch {
|
||||||
|
throw ProcessError.stdinFailed("\(id): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sendStdin(id: String, text: String) throws {
|
||||||
|
try sendStdin(id: id, bytes: Data(text.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Kill
|
||||||
|
|
||||||
|
/// Terminates a child. The `exit` event still fires exactly once.
|
||||||
|
/// stdin is dropped immediately so writers fail fast (docs/03 rule 7).
|
||||||
|
public func kill(id: String) {
|
||||||
|
if var child = children[id] {
|
||||||
|
try? child.stdin?.close()
|
||||||
|
child.stdin = nil
|
||||||
|
children[id] = child
|
||||||
|
if child.process.isRunning {
|
||||||
|
child.process.terminate()
|
||||||
|
} else {
|
||||||
|
Task { await self.didTerminate(id: id, code: child.process.terminationStatus) }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let process = captured[id] {
|
||||||
|
if process.isRunning { process.terminate() }
|
||||||
|
if captured.removeValue(forKey: id) != nil {
|
||||||
|
emit(.exit(id: id, code: process.terminationStatus))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminates every running child; returns how many were signaled
|
||||||
|
/// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149).
|
||||||
|
@discardableResult
|
||||||
|
public func killAll() -> Int {
|
||||||
|
let ids = Array(children.keys) + Array(captured.keys)
|
||||||
|
for id in ids { kill(id: id) }
|
||||||
|
return ids.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private func childEnvironment(extra: [String: String]) -> [String: String] {
|
||||||
|
var env = ProcessInfo.processInfo.environment
|
||||||
|
env["ARGYLL_NOT_INTERACTIVE"] = "1"
|
||||||
|
for (key, value) in extra { env[key] = value }
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ingestOutput(
|
||||||
|
_ data: Data,
|
||||||
|
id: String,
|
||||||
|
isStderr: Bool,
|
||||||
|
handle: FileHandle
|
||||||
|
) {
|
||||||
|
guard var child = children[id] else { return }
|
||||||
|
|
||||||
|
if data.isEmpty {
|
||||||
|
// EOF on this pipe.
|
||||||
|
handle.readabilityHandler = nil
|
||||||
|
if isStderr { child.stderrEOF = true } else { child.stdoutEOF = true }
|
||||||
|
children[id] = child
|
||||||
|
maybeFinalize(id: id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines: [String] = isStderr
|
||||||
|
? child.stderrDecoder.feed(data)
|
||||||
|
: child.stdoutDecoder.feed(data)
|
||||||
|
children[id] = child
|
||||||
|
|
||||||
|
let log = AppLogger(category: "subprocess")
|
||||||
|
for line in lines {
|
||||||
|
if !isStderr, line.hasPrefix(Self.rowColorsPrefix) {
|
||||||
|
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
|
||||||
|
emit(.jsonRow(id: id, payload: payload))
|
||||||
|
} else if isStderr {
|
||||||
|
log.warn("[\(id)] \(line)")
|
||||||
|
emit(.stderr(id: id, line: line))
|
||||||
|
} else {
|
||||||
|
log.info("[\(id)] \(line)")
|
||||||
|
emit(.stdout(id: id, line: line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func didTerminate(id: String, code: Int32) {
|
||||||
|
guard var child = children[id], !child.finalized else { return }
|
||||||
|
child.pendingExitCode = code
|
||||||
|
try? child.stdin?.close()
|
||||||
|
child.stdin = nil
|
||||||
|
children[id] = child
|
||||||
|
maybeFinalize(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits `exit` once the child has terminated *and* both pipes have
|
||||||
|
/// drained to EOF, so no buffered output is lost.
|
||||||
|
private func maybeFinalize(id: String) {
|
||||||
|
guard var child = children[id],
|
||||||
|
let code = child.pendingExitCode,
|
||||||
|
child.stdoutEOF, child.stderrEOF,
|
||||||
|
!child.finalized
|
||||||
|
else { return }
|
||||||
|
child.finalized = true
|
||||||
|
children.removeValue(forKey: id)
|
||||||
|
|
||||||
|
// Flush unterminated tail lines.
|
||||||
|
if var decoder = Optional(child.stdoutDecoder),
|
||||||
|
let tail = decoder.finish() {
|
||||||
|
if tail.hasPrefix(Self.rowColorsPrefix) {
|
||||||
|
emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)))
|
||||||
|
} else {
|
||||||
|
emit(.stdout(id: id, line: tail))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if var decoder = Optional(child.stderrDecoder),
|
||||||
|
let tail = decoder.finish() {
|
||||||
|
emit(.stderr(id: id, line: tail))
|
||||||
|
}
|
||||||
|
emit(.exit(id: id, code: code))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<!-- App Sandbox intentionally absent: ICCery must spawn Argyll tools,
|
<!-- App Sandbox intentionally absent: ICCery must spawn Argyll tools,
|
||||||
read/write user-chosen working directories, and talk to lp/CUPS. -->
|
read/write user-chosen working directories, and talk to lp/CUPS. -->
|
||||||
|
<key>com.apple.security.device.usb</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -31,21 +31,24 @@ struct SidebarView: View {
|
|||||||
|
|
||||||
Divider().overlay(Theme.border)
|
Divider().overlay(Theme.border)
|
||||||
|
|
||||||
// Preset select (`#presetSelect`). Preset engine lands in #11.
|
// Preset select (`#presetSelect`). Disabled until the preset
|
||||||
|
// engine lands in issue #11.
|
||||||
Picker("Preset", selection: .constant("none")) {
|
Picker("Preset", selection: .constant("none")) {
|
||||||
Text("No preset").tag("none")
|
Text("No preset").tag("none")
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
|
.disabled(true)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
// Calibrate Printer (`#btnCalibratePrinter`); `#calStatusChip`
|
// Calibrate Printer (`#btnCalibratePrinter`). Disabled until
|
||||||
// is hidden until the calibration library lands in #29.
|
// Stage 0 lands in issue #29; `#calStatusChip` likewise.
|
||||||
Button(action: { model.enterCalibration() }) {
|
Button(action: { model.enterCalibration() }) {
|
||||||
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
|
.disabled(true)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
|
||||||
Divider().overlay(Theme.border)
|
Divider().overlay(Theme.border)
|
||||||
@@ -56,7 +59,9 @@ struct SidebarView: View {
|
|||||||
ForEach(WizardStage.stepperStages, id: \.self) { stage in
|
ForEach(WizardStage.stepperStages, id: \.self) { stage in
|
||||||
StepperRow(
|
StepperRow(
|
||||||
stage: stage,
|
stage: stage,
|
||||||
isActive: model.stage == stage
|
isActive: model.stage == stage,
|
||||||
|
// Only Stage 1 until artefact gating lands in #4.
|
||||||
|
isEnabled: stage == .generate
|
||||||
) {
|
) {
|
||||||
model.go(to: stage)
|
model.go(to: stage)
|
||||||
}
|
}
|
||||||
@@ -74,6 +79,7 @@ struct SidebarView: View {
|
|||||||
private struct StepperRow: View {
|
private struct StepperRow: View {
|
||||||
let stage: WizardStage
|
let stage: WizardStage
|
||||||
let isActive: Bool
|
let isActive: Bool
|
||||||
|
let isEnabled: Bool
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -97,6 +103,8 @@ private struct StepperRow: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!isEnabled)
|
||||||
|
.opacity(isEnabled ? 1 : 0.45)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
|
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
|
||||||
.fill(isActive ? Theme.accent.opacity(0.15) : .clear)
|
.fill(isActive ? Theme.accent.opacity(0.15) : .clear)
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Helpers shared across ProcessManager tests. Fixture binaries are shell
|
||||||
|
/// scripts written to a temp dir — no resource bundling required.
|
||||||
|
@Suite("ProcessManager", .serialized)
|
||||||
|
struct ProcessManagerTests {
|
||||||
|
|
||||||
|
// MARK: - Fixture plumbing
|
||||||
|
|
||||||
|
private static let fixtureDir: URL = {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-pm-tests-\(UUID().uuidString)")
|
||||||
|
try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Writes a shell script fixture and returns its executable URL.
|
||||||
|
private func script(_ name: String, _ body: String) throws -> URL {
|
||||||
|
let url = Self.fixtureDir.appendingPathComponent(name)
|
||||||
|
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: url.path
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects events for `id` until `.exit`, `timeout` seconds max.
|
||||||
|
private func collect(
|
||||||
|
_ manager: ProcessManager,
|
||||||
|
id: String,
|
||||||
|
timeout: TimeInterval = 10
|
||||||
|
) async -> [ProcessEvent] {
|
||||||
|
await withCheckedContinuation { cont in
|
||||||
|
let box = Box()
|
||||||
|
Task {
|
||||||
|
for await event in manager.events() {
|
||||||
|
guard event.id == id else { continue }
|
||||||
|
box.append(event)
|
||||||
|
if case .exit = event { break }
|
||||||
|
}
|
||||||
|
if box.finish() { cont.resume(returning: box.events) }
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(timeout))
|
||||||
|
if box.finish() { cont.resume(returning: box.events) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Box: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var _events: [ProcessEvent] = []
|
||||||
|
private var finished = false
|
||||||
|
var events: [ProcessEvent] { lock.lock(); defer { lock.unlock() }; return _events }
|
||||||
|
func append(_ e: ProcessEvent) { lock.lock(); _events.append(e); lock.unlock() }
|
||||||
|
func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
@Test func streamsStdoutAndEmitsExit() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n")
|
||||||
|
async let events = collect(pm, id: "t1")
|
||||||
|
try await pm.runStreaming(id: "t1", binary: bin, arguments: [])
|
||||||
|
let evs = await events
|
||||||
|
let lines = evs.compactMap { e -> String? in
|
||||||
|
if case .stdout(_, let l) = e { return l }; return nil
|
||||||
|
}
|
||||||
|
#expect(lines == ["hello", "world"])
|
||||||
|
#expect(evs.contains(.exit(id: "t1", code: 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func routesStderrSeparately() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n")
|
||||||
|
async let evs = collect(pm, id: "t2")
|
||||||
|
try await pm.runStreaming(id: "t2", binary: bin, arguments: [])
|
||||||
|
let events = await evs
|
||||||
|
#expect(events.contains(.stdout(id: "t2", line: "out")))
|
||||||
|
#expect(events.contains(.stderr(id: "t2", line: "oops")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func stripsRowColorsJSONPrefix() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script(
|
||||||
|
"rows.sh",
|
||||||
|
"#!/bin/sh\necho 'ROW_COLORS_JSON: {\"row\":1}'\necho plain\n"
|
||||||
|
)
|
||||||
|
async let evs = collect(pm, id: "t3")
|
||||||
|
try await pm.runStreaming(id: "t3", binary: bin, arguments: [])
|
||||||
|
let events = await evs
|
||||||
|
let rows = events.compactMap { e -> String? in
|
||||||
|
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
#expect(rows == ["{\"row\":1}"])
|
||||||
|
#expect(events.contains(.stdout(id: "t3", line: "plain")))
|
||||||
|
// Prefixed lines must not leak into stdout.
|
||||||
|
#expect(!events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unterminatedTailFlushesOnExit() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n")
|
||||||
|
async let evs = collect(pm, id: "t4")
|
||||||
|
try await pm.runStreaming(id: "t4", binary: bin, arguments: [])
|
||||||
|
#expect(await evs.contains(.stdout(id: "t4", line: "no-newline")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func stdinRoundTrip() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
// Read two lines then exit naturally — a killed sh would lose its
|
||||||
|
// buffered stdio output, which is exactly the chartread pattern.
|
||||||
|
let bin = try script(
|
||||||
|
"echo.sh",
|
||||||
|
"#!/bin/sh\nIFS= read -r a; echo \"got:$a\"\nIFS= read -r b; echo \"got:$b\"\n"
|
||||||
|
)
|
||||||
|
async let evs = collect(pm, id: "t5")
|
||||||
|
try await pm.runStreaming(id: "t5", binary: bin, arguments: [])
|
||||||
|
try await pm.sendStdin(id: "t5", text: " \n")
|
||||||
|
try await pm.sendStdin(id: "t5", text: "d\n")
|
||||||
|
let events = await evs
|
||||||
|
#expect(events.contains(.stdout(id: "t5", line: "got: ")))
|
||||||
|
#expect(events.contains(.stdout(id: "t5", line: "got:d")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func duplicateIDRejected() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n")
|
||||||
|
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
||||||
|
await #expect(throws: ProcessError.duplicateID("t6")) {
|
||||||
|
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
||||||
|
}
|
||||||
|
await pm.kill(id: "t6")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func killEmitsExitAndClosesStdin() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
|
||||||
|
async let evs = collect(pm, id: "t7")
|
||||||
|
try await pm.runStreaming(id: "t7", binary: bin, arguments: [])
|
||||||
|
await pm.kill(id: "t7")
|
||||||
|
let events = await evs
|
||||||
|
// exit emitted exactly once
|
||||||
|
let exits = events.filter { if case .exit = $0 { return true }; return false }
|
||||||
|
#expect(exits.count == 1)
|
||||||
|
await #expect(throws: ProcessError.unknownID("t7")) {
|
||||||
|
try await pm.sendStdin(id: "t7", text: "d\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func killAllCountsSignaled() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n")
|
||||||
|
try await pm.runStreaming(id: "a", binary: bin, arguments: [])
|
||||||
|
try await pm.runStreaming(id: "b", binary: bin, arguments: [])
|
||||||
|
let count = await pm.killAll()
|
||||||
|
#expect(count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func capturedRunReturnsBothStreams() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n")
|
||||||
|
let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: [])
|
||||||
|
#expect(result.stdout.contains("out-data"))
|
||||||
|
#expect(result.stderr.contains("err-data"))
|
||||||
|
#expect(result.exitCode == 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
// 5000 lines each stream exceeds the 64 KiB pipe buffer.
|
||||||
|
let bin = try script(
|
||||||
|
"big.sh",
|
||||||
|
"#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n"
|
||||||
|
)
|
||||||
|
let result = try await pm.runCaptured(id: "big", binary: bin, arguments: [])
|
||||||
|
#expect(result.stdout.contains("out-4999"))
|
||||||
|
#expect(result.stderr.contains("err-4999"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func argyllEnvVarIsSet() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n")
|
||||||
|
async let evs = collect(pm, id: "t10")
|
||||||
|
try await pm.runStreaming(id: "t10", binary: bin, arguments: [])
|
||||||
|
#expect(await evs.contains(.stdout(id: "t10", line: "ANI=1")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unknownIDStdinThrows() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
await #expect(throws: ProcessError.unknownID("nope")) {
|
||||||
|
try await pm.sendStdin(id: "nope", text: "d\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("ProcessLineDecoder")
|
||||||
|
struct ProcessLineDecoderTests {
|
||||||
|
@Test func splitsAcrossChunkBoundaries() {
|
||||||
|
var d = ProcessLineDecoder()
|
||||||
|
#expect(d.feed(Data("he".utf8)) == [])
|
||||||
|
#expect(d.feed(Data("llo\nwor".utf8)) == ["hello"])
|
||||||
|
#expect(d.feed(Data("ld\n".utf8)) == ["world"])
|
||||||
|
#expect(d.finish() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func crlfIsStripped() {
|
||||||
|
var d = ProcessLineDecoder()
|
||||||
|
#expect(d.feed(Data("a\r\nb\r\n".utf8)) == ["a", "b"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func finishReturnsRemainder() {
|
||||||
|
var d = ProcessLineDecoder()
|
||||||
|
_ = d.feed(Data("x".utf8))
|
||||||
|
#expect(d.finish() == "x")
|
||||||
|
#expect(d.finish() == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("JSONAccumulator")
|
||||||
|
struct JSONAccumulatorTests {
|
||||||
|
@Test func multilinePrettyJSON() {
|
||||||
|
var acc = JSONAccumulator()
|
||||||
|
#expect(acc.feed(line: "{") == nil)
|
||||||
|
#expect(acc.feed(line: " \"k\": 1") == nil)
|
||||||
|
let done = acc.feed(line: "}")
|
||||||
|
#expect(done != nil)
|
||||||
|
let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int]
|
||||||
|
#expect(obj?["k"] == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func nonJSONLinesIgnored() {
|
||||||
|
var acc = JSONAccumulator()
|
||||||
|
#expect(acc.feed(line: "Reading instrument...") == nil)
|
||||||
|
#expect(acc.feed(line: "still text") == nil)
|
||||||
|
#expect(acc.completeData == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodeTyped() {
|
||||||
|
struct Doc: Decodable { let n: Int }
|
||||||
|
var acc = JSONAccumulator()
|
||||||
|
// Split so the doc completes on the second feed.
|
||||||
|
#expect(acc.feed(line: "{\"n\":") == nil)
|
||||||
|
let data = acc.feed(line: "7}")
|
||||||
|
#expect(data != nil)
|
||||||
|
let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) }
|
||||||
|
#expect(doc?.n == 7)
|
||||||
|
#expect(acc.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("LogSanitizer")
|
||||||
|
struct LogSanitizerTests {
|
||||||
|
@Test func homeIsRewritten() {
|
||||||
|
let path = "\(NSHomeDirectory())/Documents/foo.ti1"
|
||||||
|
#expect(LogSanitizer.sanitize(path) == "~/Documents/foo.ti1")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user