Skip to content

Stop opening a null-device handle per Capture and spinner task - #629

Draft
GoodForOneFare wants to merge 1 commit into
mainfrom
gordo-capture-fd-leak
Draft

Stop opening a null-device handle per Capture and spinner task#629
GoodForOneFare wants to merge 1 commit into
mainfrom
gordo-capture-fd-leak

Conversation

@GoodForOneFare

@GoodForOneFare GoodForOneFare commented Aug 14, 2026

Copy link
Copy Markdown
Member

Bug

Capture#initialize and SpinGroup#add both defaulted duplicate_output_to: to a freshly opened File::NULL handle:

duplicate_output_to: File.open(File::NULL, 'w')  # Capture
duplicate_output_to: File.new(File::NULL, 'w')   # SpinGroup#add

Every capture therefore opened an OS handle solely to discard writes, including one for every spinner task. Ruby closes these handles when their File objects are finalized, but they remain open while captures or queued task closures are live.

Verified on main: constructing 10 captures opens 10 new descriptors.

Fix

Default duplicate_output_to: to nil and skip the duplicate write when unset. This avoids opening a descriptor just to discard output.

The option is typed as io_like? (IO | StringIO | nil) so its signature matches the streams accepted elsewhere in cli-ui. Explicit duplicate streams continue to receive captured stdout and remain caller-owned. An IOError from a closed duplicate stream is ignored, matching the module-level duplicate writer.

The module-level StdoutRouter.duplicate_output_to used by CLI::UI.log_output_to is a separate mechanism and is otherwise unchanged.

Tests

  • Capture does not open File::NULL by default.
  • SpinGroup#add does not open File::NULL for each task.
  • A StringIO duplicate receives captured stdout and is not closed by the capture.
  • A duplicate stream closed during capture does not interrupt the caller's block.

🤖 Generated with Claude Code

(posted by an LLM bot on behalf of Gord)

Capture#initialize and SpinGroup#add both defaulted duplicate_output_to to a freshly opened File::NULL handle. Every capture, including one per spinner task, therefore opened an OS handle solely to discard writes. Ruby closes these handles when their File objects are finalized, but they remain open while captures or queued task closures are live.

Default duplicate_output_to to nil and skip the duplicate write when unset. Type the option as io_like? so StringIO remains supported, and ignore IOError from caller-owned duplicate streams to match the module-level duplicate writer.

The regression tests deterministically assert that the two defaults do not open the null device and cover both StringIO duplication and a duplicate stream closed during capture.

Co-authored-by: River <river@shopify.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Assisted-By: devx/cceb8550-0aa0-4aa5-b0d0-bd55a129e07c
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.

1 participant