Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa9a2042bf | ||
|
|
e48c3f6840 | ||
|
|
7751701208 | ||
|
|
c539507d5d | ||
|
|
0ffcf5ea91 |
@@ -27,5 +27,4 @@ ICCery.xcodeproj/
|
|||||||
Release/
|
Release/
|
||||||
notarization/
|
notarization/
|
||||||
build/
|
build/
|
||||||
docs/megaplans/*
|
|
||||||
docs/megaplans
|
docs/megaplans
|
||||||
|
|||||||
@@ -79,14 +79,17 @@ public enum ArtefactProbe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve an explicit profile URL, flipping `.icc` ↔ `.icm` when the
|
/// Resolve an explicit profile URL, flipping `.icc` ↔ `.icm` when the
|
||||||
/// requested path is missing (#69 / issue #83).
|
/// requested path is missing (#69 / issue #83). Any other extension
|
||||||
|
/// (`.mpp`, `.txt`, …) is returned unchanged — never rewritten.
|
||||||
public static func resolveProfile(
|
public static func resolveProfile(
|
||||||
_ url: URL,
|
_ url: URL,
|
||||||
fileManager: FileManager = .default
|
fileManager: FileManager = .default
|
||||||
) -> URL {
|
) -> URL {
|
||||||
if fileManager.fileExists(atPath: url.path) { return url }
|
if fileManager.fileExists(atPath: url.path) { return url }
|
||||||
let altExt = url.pathExtension.lowercased() == "icc" ? "icm" : "icc"
|
let ext = url.pathExtension.lowercased()
|
||||||
let alt = url.deletingPathExtension().appendingPathExtension(altExt)
|
guard ext == "icc" || ext == "icm" else { return url }
|
||||||
|
let alt = url.deletingPathExtension()
|
||||||
|
.appendingPathExtension(ext == "icc" ? "icm" : "icc")
|
||||||
return fileManager.fileExists(atPath: alt.path) ? alt : url
|
return fileManager.fileExists(atPath: alt.path) ? alt : url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -145,9 +145,7 @@ public actor ProcessManager {
|
|||||||
)
|
)
|
||||||
let process = prepared.process
|
let process = prepared.process
|
||||||
|
|
||||||
AppLogger(category: "process").debug(
|
logSpawn(id: id, binary: binary, arguments: arguments, captured: false)
|
||||||
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
|
||||||
)
|
|
||||||
|
|
||||||
children[id] = RunningChild(
|
children[id] = RunningChild(
|
||||||
process: process,
|
process: process,
|
||||||
@@ -214,9 +212,7 @@ public actor ProcessManager {
|
|||||||
let stdoutPipe = prepared.stdoutPipe
|
let stdoutPipe = prepared.stdoutPipe
|
||||||
let stderrPipe = prepared.stderrPipe
|
let stderrPipe = prepared.stderrPipe
|
||||||
|
|
||||||
AppLogger(category: "process").debug(
|
logSpawn(id: id, binary: binary, arguments: arguments, captured: true)
|
||||||
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Register and set up the termination hand-off before run() so
|
// Register and set up the termination hand-off before run() so
|
||||||
// a very fast exit is never missed (#50, #52).
|
// a very fast exit is never missed (#50, #52).
|
||||||
@@ -466,6 +462,18 @@ public actor ProcessManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private nonisolated func logSpawn(
|
||||||
|
id: String,
|
||||||
|
binary: URL,
|
||||||
|
arguments: [String],
|
||||||
|
captured: Bool
|
||||||
|
) {
|
||||||
|
let prefix = captured ? "spawn(captured)" : "spawn"
|
||||||
|
AppLogger(category: "process").debug(
|
||||||
|
"\(prefix) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// `terminationHandler` can lose a fast-exit race on a loaded host;
|
/// `terminationHandler` can lose a fast-exit race on a loaded host;
|
||||||
/// `waitUntilExit` on a detached thread is the fallback (#50, #52).
|
/// `waitUntilExit` on a detached thread is the fallback (#50, #52).
|
||||||
/// The handler is attached before `run()`; the wait thread starts
|
/// The handler is attached before `run()`; the wait thread starts
|
||||||
|
|||||||
@@ -33,16 +33,16 @@ public struct CalibrationIdentity: Equatable, Sendable {
|
|||||||
|
|
||||||
/// Derive identity from the live wizard basename and the persisted
|
/// Derive identity from the live wizard basename and the persisted
|
||||||
/// original. A non-empty persisted original wins over a `CAL_` live
|
/// original. A non-empty persisted original wins over a `CAL_` live
|
||||||
/// name (Force Quit mid-calibration).
|
/// name (Force Quit mid-calibration). An empty live basename always
|
||||||
|
/// produces an empty identity — a persisted original must never
|
||||||
|
/// resurrect a target that no longer exists (#83).
|
||||||
public static func parse(liveBasename: String, persistedOriginal: String) -> CalibrationIdentity {
|
public static func parse(liveBasename: String, persistedOriginal: String) -> CalibrationIdentity {
|
||||||
if liveBasename.isEmpty && persistedOriginal.isEmpty {
|
guard !liveBasename.isEmpty else {
|
||||||
return CalibrationIdentity(originalBasename: "", calibrationBasename: "")
|
return CalibrationIdentity(originalBasename: "", calibrationBasename: "")
|
||||||
}
|
}
|
||||||
let original: String
|
let original: String
|
||||||
if liveBasename.hasPrefix("CAL_") {
|
if liveBasename.hasPrefix("CAL_") {
|
||||||
original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal
|
original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal
|
||||||
} else if liveBasename.isEmpty {
|
|
||||||
original = persistedOriginal
|
|
||||||
} else {
|
} else {
|
||||||
original = liveBasename
|
original = liveBasename
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,34 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Calibration targen from foo runs as process id targen_CAL_foo")
|
||||||
|
func calibrationTargenProcessId() async throws {
|
||||||
|
let testRoot = try makeTestDir()
|
||||||
|
let runner = makeRunner()
|
||||||
|
let events = ProcessManager.shared.events()
|
||||||
|
// Subscribed before spawn; the exit event is emitted before
|
||||||
|
// runCalibrationTargen returns, so this always terminates.
|
||||||
|
let sawExit = Task {
|
||||||
|
for await event in events {
|
||||||
|
guard event.id == "targen_CAL_foo" else { continue }
|
||||||
|
if case .exit = event { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let config = CalibrationTargenConfig(
|
||||||
|
colourSpace: .rgb,
|
||||||
|
steps: 21,
|
||||||
|
basename: "foo",
|
||||||
|
workingDirectory: testRoot
|
||||||
|
)
|
||||||
|
|
||||||
|
let url = try await runner.runCalibrationTargen(config: config)
|
||||||
|
|
||||||
|
#expect(url.lastPathComponent == "CAL_foo.ti1")
|
||||||
|
#expect(await sawExit.value)
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("printcal captured run creates .cal")
|
@Test("printcal captured run creates .cal")
|
||||||
func printcalProducesCal() async throws {
|
func printcalProducesCal() async throws {
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
|
|||||||
@@ -125,26 +125,102 @@ struct ArtefactFilesTests {
|
|||||||
|
|
||||||
@Suite("ArtefactProbe profile resolve")
|
@Suite("ArtefactProbe profile resolve")
|
||||||
struct ArtefactProbeProfileTests {
|
struct ArtefactProbeProfileTests {
|
||||||
@Test("basename probe prefers .icm")
|
private func makeDir() throws -> URL {
|
||||||
func icmWins() throws {
|
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("probe-\(UUID().uuidString)")
|
.appendingPathComponent("probe-\(UUID().uuidString)")
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Basename probe matrix (#69)
|
||||||
|
|
||||||
|
@Test("basename probe: only .icc exists")
|
||||||
|
func onlyIcc() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
try Data("icc".utf8).write(to: icc)
|
||||||
|
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icc.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("basename probe: only .icm exists")
|
||||||
|
func onlyIcm() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
|
try Data("icm".utf8).write(to: icm)
|
||||||
|
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icm.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("basename probe prefers .icm")
|
||||||
|
func icmWins() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
|
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
|
||||||
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm"))
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
|
try Data("icm".utf8).write(to: icm)
|
||||||
let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir)
|
let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir)
|
||||||
#expect(url?.pathExtension == "icm")
|
#expect(url?.path == icm.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("basename probe: neither exists returns nil")
|
||||||
|
func neitherExists() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Explicit URL matrix (#69 / #83)
|
||||||
|
|
||||||
|
@Test("explicit existing .icc wins even when .icm exists")
|
||||||
|
func explicitIccWins() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
try Data("icc".utf8).write(to: icc)
|
||||||
|
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm"))
|
||||||
|
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("explicit existing .icm wins even when .icc exists")
|
||||||
|
func explicitIcmWins() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
|
||||||
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
|
try Data("icm".utf8).write(to: icm)
|
||||||
|
#expect(ArtefactProbe.resolveProfile(icm).path == icm.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("explicit missing .icc flips to sibling .icm")
|
@Test("explicit missing .icc flips to sibling .icm")
|
||||||
func flipExtension() throws {
|
func flipExtension() throws {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = try makeDir()
|
||||||
.appendingPathComponent("probe-\(UUID().uuidString)")
|
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
let icc = dir.appendingPathComponent("job.icc")
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
let icm = dir.appendingPathComponent("job.icm")
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
try Data("icm".utf8).write(to: icm)
|
try Data("icm".utf8).write(to: icm)
|
||||||
let resolved = ArtefactProbe.resolveProfile(icc)
|
let resolved = ArtefactProbe.resolveProfile(icc)
|
||||||
#expect(resolved.path == icm.path)
|
#expect(resolved.path == icm.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("explicit missing .icm flips to sibling .icc")
|
||||||
|
func flipToIcc() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
|
try Data("icc".utf8).write(to: icc)
|
||||||
|
#expect(ArtefactProbe.resolveProfile(icm).path == icc.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("explicit missing both returns the original URL")
|
||||||
|
func missingBoth() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("unrelated extension is never rewritten")
|
||||||
|
func unrelatedExtension() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let mpp = dir.appendingPathComponent("job.mpp")
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
try Data("icc".utf8).write(to: icc)
|
||||||
|
// Even though a sibling .icc exists, a missing .mpp stays .mpp.
|
||||||
|
#expect(ArtefactProbe.resolveProfile(mpp).path == mpp.path)
|
||||||
|
let txt = dir.appendingPathComponent("job.txt")
|
||||||
|
#expect(ArtefactProbe.resolveProfile(txt).path == txt.path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Issue #83 — canonical `CAL_` / original-stem pairing.
|
||||||
|
@Suite("CalibrationIdentity")
|
||||||
|
struct CalibrationIdentityTests {
|
||||||
|
@Test("live foo, no persisted")
|
||||||
|
func livePlain() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
|
||||||
|
#expect(id.originalBasename == "foo")
|
||||||
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("live foo ignores stale persisted")
|
||||||
|
func livePlainIgnoresPersisted() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar")
|
||||||
|
#expect(id.originalBasename == "foo")
|
||||||
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("live CAL_foo, persisted foo")
|
||||||
|
func liveCalPersisted() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
|
||||||
|
#expect(id.originalBasename == "foo")
|
||||||
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("live CAL_foo, empty persisted strips prefix")
|
||||||
|
func liveCalNoPersist() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
|
||||||
|
#expect(id.originalBasename == "foo")
|
||||||
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("persisted original wins over CAL_ live")
|
||||||
|
func persistedWins() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
|
||||||
|
#expect(id.originalBasename == "bar")
|
||||||
|
#expect(id.calibrationBasename == "CAL_bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("empty live yields empty identity even with persisted original")
|
||||||
|
func emptyLiveWithPersisted() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo")
|
||||||
|
#expect(id.originalBasename.isEmpty)
|
||||||
|
#expect(id.calibrationBasename.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("empty live, empty persisted")
|
||||||
|
func emptyLive() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
|
||||||
|
#expect(id.originalBasename.isEmpty)
|
||||||
|
#expect(id.calibrationBasename.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("prefix is idempotent on already-prefixed input")
|
||||||
|
func alreadyPrefixed() {
|
||||||
|
#expect(CalibrationIdentity.prefix("CAL_foo") == "CAL_foo")
|
||||||
|
#expect(CalibrationIdentity.prefix("foo") == "CAL_foo")
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "")
|
||||||
|
#expect(id.originalBasename == "CAL_foo")
|
||||||
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("prefix never invents a name from empty input")
|
||||||
|
func prefixEmpty() {
|
||||||
|
#expect(CalibrationIdentity.prefix("").isEmpty)
|
||||||
|
#expect(CalibrationIdentity.strip("foo") == "foo")
|
||||||
|
#expect(CalibrationIdentity.strip("CAL_foo") == "foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("runner process id for a calibration targen is targen_CAL_*")
|
||||||
|
func processIdMatches() {
|
||||||
|
let cal = CalibrationIdentity.prefix("foo")
|
||||||
|
#expect(ProcessID.targen(cal) == "targen_CAL_foo")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,41 +82,3 @@ struct JSONFileStoreTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("CalibrationIdentity")
|
|
||||||
struct CalibrationIdentityTests {
|
|
||||||
@Test("live foo, no persisted")
|
|
||||||
func livePlain() {
|
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
|
|
||||||
#expect(id.originalBasename == "foo")
|
|
||||||
#expect(id.calibrationBasename == "CAL_foo")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("live CAL_foo, persisted foo")
|
|
||||||
func liveCalPersisted() {
|
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
|
|
||||||
#expect(id.originalBasename == "foo")
|
|
||||||
#expect(id.calibrationBasename == "CAL_foo")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("live CAL_foo, empty persisted strips prefix")
|
|
||||||
func liveCalNoPersist() {
|
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
|
|
||||||
#expect(id.originalBasename == "foo")
|
|
||||||
#expect(id.calibrationBasename == "CAL_foo")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("persisted original wins")
|
|
||||||
func persistedWins() {
|
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
|
|
||||||
#expect(id.originalBasename == "bar")
|
|
||||||
#expect(id.calibrationBasename == "CAL_bar")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("empty live does not invent a name")
|
|
||||||
func emptyLive() {
|
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
|
|
||||||
#expect(id.originalBasename.isEmpty)
|
|
||||||
#expect(id.calibrationBasename.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -58,6 +58,59 @@ struct ProcessManagerTests {
|
|||||||
func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true }
|
func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subscribes synchronously (registration happens inside `events()`)
|
||||||
|
/// then records every event for `id` until the task is cancelled.
|
||||||
|
/// Unlike `collect`, observation continues past `.exit` so tests can
|
||||||
|
/// prove exactly-once exit emission.
|
||||||
|
private func observe(
|
||||||
|
_ manager: ProcessManager,
|
||||||
|
id: String,
|
||||||
|
into box: Box
|
||||||
|
) -> Task<Void, Never> {
|
||||||
|
let stream = manager.events()
|
||||||
|
return Task {
|
||||||
|
for await event in stream {
|
||||||
|
guard event.id == id else { continue }
|
||||||
|
box.append(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exitCount(in box: Box) -> Int {
|
||||||
|
box.events.filter { if case .exit = $0 { return true }; return false }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForExit(in box: Box, timeout: TimeInterval = 10) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if exitCount(in: box) > 0 { return true }
|
||||||
|
try? await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForFile(_ url: URL, timeout: TimeInterval = 5) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if FileManager.default.fileExists(atPath: url.path) { return true }
|
||||||
|
try? await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForRunning(
|
||||||
|
_ manager: ProcessManager,
|
||||||
|
id: String,
|
||||||
|
timeout: TimeInterval = 5
|
||||||
|
) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if await manager.isRunning(id) { return true }
|
||||||
|
try? await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Tests
|
// MARK: - Tests
|
||||||
|
|
||||||
@Test func streamsStdoutAndEmitsExit() async throws {
|
@Test func streamsStdoutAndEmitsExit() async throws {
|
||||||
@@ -214,6 +267,145 @@ struct ProcessManagerTests {
|
|||||||
try await pm.sendStdin(id: "nope", text: "d\n")
|
try await pm.sendStdin(id: "nope", text: "d\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func explicitPartialFlushEmitsRowColorsJSON() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let marker = Self.fixtureDir
|
||||||
|
.appendingPathComponent("partial-row-ready-\(UUID().uuidString)")
|
||||||
|
let bin = try script(
|
||||||
|
"partial-row.sh",
|
||||||
|
"#!/bin/sh\nprintf 'ROW_COLORS_JSON: {\"row\":9}'\ntouch \"$1\"\nsleep 30\n"
|
||||||
|
)
|
||||||
|
let box = Box()
|
||||||
|
let observer = observe(pm, id: "t11", into: box)
|
||||||
|
try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path])
|
||||||
|
#expect(await waitForFile(marker))
|
||||||
|
// Retry the flush so the pipe-ingest task can win the actor race
|
||||||
|
// on a loaded host; the first successful flush emits the row.
|
||||||
|
var flushed = false
|
||||||
|
for _ in 0..<50 {
|
||||||
|
await pm.flushPartialLine(id: "t11")
|
||||||
|
if box.events.contains(where: { if case .jsonRow = $0 { return true }; return false }) {
|
||||||
|
flushed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: .milliseconds(20))
|
||||||
|
}
|
||||||
|
#expect(flushed)
|
||||||
|
await pm.kill(id: "t11")
|
||||||
|
#expect(await waitForExit(in: box))
|
||||||
|
observer.cancel()
|
||||||
|
let events = box.events
|
||||||
|
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\":9}"])
|
||||||
|
// Prefixed tails must not leak into stdout, even via finalize.
|
||||||
|
#expect(!events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}")))
|
||||||
|
#expect(exitCount(in: box) == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unterminatedRowTailFinalizesAsJSONRow() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script(
|
||||||
|
"row-tail.sh",
|
||||||
|
"#!/bin/sh\nprintf 'ROW_COLORS_JSON: {\"row\":42}'\n"
|
||||||
|
)
|
||||||
|
let box = Box()
|
||||||
|
let observer = observe(pm, id: "t12", into: box)
|
||||||
|
try await pm.runStreaming(id: "t12", binary: bin, arguments: [])
|
||||||
|
#expect(await waitForExit(in: box))
|
||||||
|
observer.cancel()
|
||||||
|
let events = box.events
|
||||||
|
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\":42}"])
|
||||||
|
#expect(!events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}")))
|
||||||
|
let rowIndex = events.firstIndex {
|
||||||
|
if case .jsonRow = $0 { return true }; return false
|
||||||
|
}
|
||||||
|
let exitIndexes = events.indices.filter {
|
||||||
|
if case .exit = events[$0] { return true }; return false
|
||||||
|
}
|
||||||
|
#expect(exitIndexes.count == 1)
|
||||||
|
if let rowIndex, let exitIndex = exitIndexes.first {
|
||||||
|
#expect(rowIndex < exitIndex)
|
||||||
|
} else {
|
||||||
|
Issue.record("expected a jsonRow before the exit event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fastStreamingExitEmitsExactlyOneExit() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n")
|
||||||
|
let box = Box()
|
||||||
|
let observer = observe(pm, id: "t13", into: box)
|
||||||
|
try await pm.runStreaming(id: "t13", binary: bin, arguments: [])
|
||||||
|
#expect(await waitForExit(in: box))
|
||||||
|
// The grace window must outlast the 2 s finalize watchdog so a
|
||||||
|
// duplicate emission from it would be observed.
|
||||||
|
try await Task.sleep(for: .milliseconds(2500))
|
||||||
|
observer.cancel()
|
||||||
|
#expect(box.events == [.exit(id: "t13", code: 0)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fastCapturedExitEmitsExactlyOneExit() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n")
|
||||||
|
let box = Box()
|
||||||
|
let observer = observe(pm, id: "t14", into: box)
|
||||||
|
let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: [])
|
||||||
|
#expect(result.exitCode == 7)
|
||||||
|
// Both the termination handler and the waitUntilExit watchdog
|
||||||
|
// resume the same box; give the slower path time to fire.
|
||||||
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
|
observer.cancel()
|
||||||
|
#expect(box.events == [.exit(id: "t14", code: 7)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func capturedRunSetsArgyllNotInteractive() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script(
|
||||||
|
"cap-env.sh",
|
||||||
|
"#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n"
|
||||||
|
)
|
||||||
|
let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: [])
|
||||||
|
#expect(result.stdout == "ANI=1\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func killAllTerminatesStreamingAndCapturedChildren() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let marker = Self.fixtureDir
|
||||||
|
.appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)")
|
||||||
|
let slowBin = try script("mixed-slow.sh", "#!/bin/sh\nsleep 30\n")
|
||||||
|
let capBin = try script("mixed-cap.sh", "#!/bin/sh\ntouch \"$1\"\nsleep 30\n")
|
||||||
|
let streamBox = Box()
|
||||||
|
let capBox = Box()
|
||||||
|
let streamObserver = observe(pm, id: "t16", into: streamBox)
|
||||||
|
let capObserver = observe(pm, id: "t17", into: capBox)
|
||||||
|
try await pm.runStreaming(id: "t16", binary: slowBin, arguments: [])
|
||||||
|
let capTask = Task {
|
||||||
|
try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path])
|
||||||
|
}
|
||||||
|
#expect(await waitForFile(marker))
|
||||||
|
#expect(await waitForRunning(pm, id: "t16"))
|
||||||
|
#expect(await waitForRunning(pm, id: "t17"))
|
||||||
|
#expect(await pm.killAll() == 2)
|
||||||
|
_ = try await capTask.value
|
||||||
|
#expect(await waitForExit(in: streamBox))
|
||||||
|
#expect(await waitForExit(in: capBox))
|
||||||
|
// Grace window outlasts the streaming finalize watchdog.
|
||||||
|
try await Task.sleep(for: .milliseconds(2500))
|
||||||
|
streamObserver.cancel()
|
||||||
|
capObserver.cancel()
|
||||||
|
#expect(!(await pm.isRunning("t16")))
|
||||||
|
#expect(!(await pm.isRunning("t17")))
|
||||||
|
#expect(exitCount(in: streamBox) == 1)
|
||||||
|
#expect(exitCount(in: capBox) == 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ProcessLineDecoder")
|
@Suite("ProcessLineDecoder")
|
||||||
|
|||||||
Reference in New Issue
Block a user