Make the app reliable
Treat child output as a protocol
Define stable machine-readable output instead of scraping prose meant for a person.
The previous module launched tools and read their output in one gulp. That breaks down the moment you need progress. Think of a long export where the interface shows a moving bar.
Now the child’s stdout is not text you glance at. It’s an interface between two programs, and it deserves the same care as a network API.
Don’t scrape prose
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. The progress bar freezes at zero while the work completes fine. Nobody wrote a bug. A sentence changed.
Define the messages
The fix is to define the messages. If you control the tool, make it emit JSON Lines, one JSON object per line, on stdout. 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)
}
Buffer until a newline
Streaming reads deliver arbitrary chunks, not lines. A read can end mid-message, so collect bytes 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)
}
}
}
The while loop matters. One chunk can hold two complete messages, and you want to handle both before waiting for more data.
Keep the failures
Decoding failures at this boundary are information. Keep a bounded tail of recent raw lines, the last hundred say. When the child fails, you can show or log what it actually said, instead of a bare “exit code 1”.
Version the protocol
If the protocol will evolve, add a version field to the first message and reject major versions you don’t understand. That turns a future incompatibility into a clear error instead of a subtle misparse.
Verify with hostile input
Feed the parser three things: 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. Real pipes don’t care where your messages end.
Lesson completed