Skip to content

Conversation

@GoodForOneFare
Copy link
Member

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

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 <[email protected]>
Copy link
Contributor

@donk-shopify donk-shopify left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've left a few timing related questions based on my reading of IO.select, readpartial.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redo this comment in context with the new implementation so that readers understand why the readers.each loop needs to be attempted.

rescue IOError
# Pipe closed - this is expected when process exits
io.close
ios.delete(io)
Copy link
Contributor

@donk-shopify donk-shopify Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the process has exited, the size of this array controls the loop exit. If IO.select continues to timeout, this code won't be reached. It looks like there's a possibility of:

  1. IO.select timeout
  2. Skip the readers loop
  3. Again determine that process_reaped should be true
  4. Repeat

I guess this depends on what IO.select does when process_reaped is true. It seems like it might raise EOFError or Errno::EPIPE. Perhaps just handling those would be a good way to exit the loop.

I'm not convinced that this could happen. If it did occur, then the next if early loop control would've handled it. Perhaps unintentionally.

Since this condition is logically possible, the new implementation should also handle it. Preferably explicitly.

end

# Ensure we've fully reaped the child (if WNOHANG never caught it)
Process.wait(pid) unless process_reaped
Copy link
Contributor

@donk-shopify donk-shopify Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loop isn't exited until process_reaped is true.

If the loop somehow did exit with a live process that needs waiting on, there's the potential for data to appear on the pipes. Outside the loop, nothing is calling readpartial. The lingering data from the loop should be in previous_trailing but new data on the pipes (assuming the process is live) seems to be lost.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants