From 55b548a608ba4bcf6dd85dacabecbdb7b287e074 Mon Sep 17 00:00:00 2001 From: Gord Pearson Date: Wed, 7 Jan 2026 09:35:33 -0500 Subject: [PATCH] Improve output capture for short-lived processes in System module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the system method to reliably capture all output from processes that exit quickly, before all their output has been read. The previous implementation could miss output if the process exited between IO checks. Key changes: - Use IO.select with timeout to efficiently wait for data - Non-blocking process reaping with Process::WNOHANG - Loop continues until both pipes are closed AND process is reaped - Final flush of any trailing partial UTF-8 characters Also fixes RuboCop warnings: - Use safe navigation operator for readers check - Remove EOFError from rescue (IOError is the parent class) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/cli/kit/system.rb | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/lib/cli/kit/system.rb b/lib/cli/kit/system.rb index 822e978..7bb4a6f 100644 --- a/lib/cli/kit/system.rb +++ b/lib/cli/kit/system.rb @@ -157,23 +157,38 @@ def system(cmd, *args, sudo: false, env: ENV.to_h, stdin: nil, **kwargs, &block) } end + process_reaped = false previous_trailing = Hash.new('') - loop do - break if Process.wait(pid, Process::WNOHANG) + ios = [err_r, out_r] - ios = [err_r, out_r].reject(&:closed?) - next if ios.empty? + loop do + # Break only when child exited AND there are no more open pipes + break if ios.empty? && process_reaped readers, = IO.select(ios, [], [], 1) - next if readers.nil? # If IO.select times out we iterate again so we can check if the process has exited - readers.each do |io| + readers&.each do |io| data, trailing = split_partial_characters(io.readpartial(4096)) handlers[io].call(previous_trailing[io] + data) previous_trailing[io] = trailing rescue IOError + # Pipe closed - this is expected when process exits io.close + ios.delete(io) end + + # Non-blocking reap of child; only do it once + process_reaped ||= !Process.wait(pid, Process::WNOHANG).nil? + end + + # Ensure we've fully reaped the child (if WNOHANG never caught it) + Process.wait(pid) unless process_reaped + + # Flush any remaining trailing fragments so nothing is lost + previous_trailing.each do |io, trailing| + next if trailing.empty? + + handlers[io].call(trailing) end $CHILD_STATUS