Compare commits

..
Author SHA1 Message Date
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 5bb057a1e3 ProcessManager: spawn / stdin / kill / captured / event bus (#2)
- actor ProcessManager: runStreaming + runCaptured (concurrent pipe
  drain — no 64 KiB deadlock), sendStdin with independent stdin map
  (#84), kill/killAll, duplicate-id rejection (#116)
- Multicast AsyncStream<ProcessEvent> bus: stdout/stderr/exit/error/
  jsonRow (ROW_COLORS_JSON: prefix stripped)
- exit emitted exactly once, gated on both pipes reaching EOF so
  buffered output is never lost on fast exits or kills
- ARGYLL_NOT_INTERACTIVE=1 on every child; argv logged with ~ home
  sanitization; subprocess stdout→info, stderr→warn
- ProcessLineDecoder (byte-split at \n, UTF-8 safe, CRLF, unterminated
  tail flush), JSONAccumulator (multiline JSON for instlist/profcheck/
  manifest), ProcessID conventions
- 19 new tests incl. large-output captured run and stdin round-trip
- Fixup: entitlements gain com.apple.security.device.usb; sidebar
  preset/Calibrate disabled and only Stage 1 enabled per #1 AC

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 18:51:55 +01:00
gronod b998b48abf Merge pull request 'App scaffold & wizard shell (#1)' (#33) from feat/1-app-scaffold into milestone/m1-foundation 2026-09-08 18:30:28 +01:00
10 changed files with 855 additions and 4 deletions
@@ -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 byteline 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))
}
}
+2
View File
@@ -4,5 +4,7 @@
<dict>
<!-- App Sandbox intentionally absent: ICCery must spawn Argyll tools,
read/write user-chosen working directories, and talk to lp/CUPS. -->
<key>com.apple.security.device.usb</key>
<true/>
</dict>
</plist>
+12 -4
View File
@@ -31,21 +31,24 @@ struct SidebarView: View {
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")) {
Text("No preset").tag("none")
}
.pickerStyle(.menu)
.disabled(true)
.padding(.horizontal, 12)
.padding(.vertical, 8)
// Calibrate Printer (`#btnCalibratePrinter`); `#calStatusChip`
// is hidden until the calibration library lands in #29.
// Calibrate Printer (`#btnCalibratePrinter`). Disabled until
// Stage 0 lands in issue #29; `#calStatusChip` likewise.
Button(action: { model.enterCalibration() }) {
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
.frame(maxWidth: .infinity)
}
.controlSize(.large)
.disabled(true)
.padding(.horizontal, 12)
Divider().overlay(Theme.border)
@@ -56,7 +59,9 @@ struct SidebarView: View {
ForEach(WizardStage.stepperStages, id: \.self) { stage in
StepperRow(
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)
}
@@ -74,6 +79,7 @@ struct SidebarView: View {
private struct StepperRow: View {
let stage: WizardStage
let isActive: Bool
let isEnabled: Bool
let action: () -> Void
var body: some View {
@@ -97,6 +103,8 @@ private struct StepperRow: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!isEnabled)
.opacity(isEnabled ? 1 : 0.45)
.background(
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
.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")
}
}