Make the app reliable
Treat child output as a protocol
Define stable machine-readable output instead of scraping prose meant for a person.
12 minute lesson
The previous module launched tools and read their output in one gulp. That breaks down the moment you need progress — a long export where the interface shows a moving bar. Now the child’s stdout is not text you glance at. It is an interface between two programs, and it deserves the same care as a network API.
Scraping human-oriented output is the tempting shortcut. The tool prints Processed 12 of 40 files, so you match it with a regex and ship. Then a later version of the tool rewords its messages, your regex silently stops matching, and the progress bar freezes at zero while the work completes fine. Nobody wrote a bug. A sentence changed.
The fix is to define the messages. If you control the tool, make it emit JSON Lines — one JSON object per line — on stdout, and keep human diagnostics on stderr:
{"event":"progress","completed":12,"total":40}
{"event":"finished","output":"recording.mp4"}
Each line stands alone. Decode complete lines into a typed value so unknown input fails loudly at the boundary:
struct ToolEvent: Decodable {
let event: String
let completed: Int?
let total: Int?
let output: String?
}
func parse(line: Data) throws -> ToolEvent {
try JSONDecoder().decode(ToolEvent.self, from: line)
}
Streaming reads deliver arbitrary chunks, not lines. A read can end mid-message, so buffer until a newline appears:
var buffer = Data()
func receive(_ chunk: Data) {
buffer.append(chunk)
while let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) {
let line = buffer[..<newline]
buffer.removeSubrange(...newline)
if let event = try? parse(line: line) {
handle(event)
}
}
}
Decoding failures at this boundary are information. Keep a bounded tail of recent raw lines — the last hundred, say — so when the child fails you can show or log what it actually said, instead of a bare “exit code 1”.
If the protocol will evolve, add a version field to the first message and reject major versions you do not understand. That turns a future incompatibility into a clear error instead of a subtle misparse.
Verify with hostile input. Feed the parser a message split across two chunks, two messages in one chunk, and a line of plain prose. The first two must decode correctly. The third must be rejected without taking the app down. If your parser only handles one-message-per-read, it works in tests and fails under real pipe timing.
Lesson completed