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 output 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

  • Ten simultaneously live Capture objects add no descriptors.
  • Twenty pending spinner tasks add no descriptors while queued or after completion.
  • Explicit StringIO duplicates receive captured stdout through both Capture and SpinGroup#add and remain caller-owned.
  • A merged-output capture duplicates stderr.
  • A duplicate stream closed during capture does not interrupt the caller block.

The descriptor tests inspect /dev/fd on macOS and Linux and skip where it is unavailable. They compare descriptor sets and assert only that no descriptors were added, so unrelated File objects finalized between samples cannot make the test fail merely because the total count decreased.

🤖 Generated with Claude Code

(posted by an LLM bot on behalf of Gord)

@GoodForOneFare
GoodForOneFare force-pushed the gordo-capture-fd-leak branch 3 times, most recently from 2837642 to 7a22970 Compare August 17, 2026 12:45
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 compare process descriptor sets while captures and pending spinner tasks remain live, asserting only on additions so unrelated File finalization cannot make the tests flaky. They also cover explicit StringIO duplication through Capture and SpinGroup, merged stderr, 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