Skip to content

Keep the stderr drain thread off rspec doubles in connect specs - #164

Merged
ya-luotao merged 1 commit into
mainfrom
test/stderr-drain-double-leak
Oct 3, 2026
Merged

ya-luotao merged 1 commit into
mainfrom
test/stderr-drain-double-leak

Conversation

@ya-luotao

@ya-luotao ya-luotao commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

What and why

CI on main failed once, at 07ad108 (between the merges of #158 and #159), on the Ruby 4.0 leg:

rspec ./spec/unit/subprocess_cli_transport_spec.rb:921
  expected block to not output to stderr, but output
  "#<Thread:0x… subprocess_cli_transport.rb:401 run> terminated with exception …"

The output came from a different example. Nine popen3 stubs (22 #connect calls over three helpers and
six inline examples in subprocess_cli_transport_spec.rb, plus the helper in query_run_end_spec.rb)
hand #connect an instance_double(IO) as stderr. #connect starts a thread that drains stderr, and these
examples never close the transport, so the thread outlives them:

  • if it runs during its own example, #each_line is an unexpected message;
  • if it runs between examples, rspec-mocks raises OutsideOfExampleError, a StandardError that the drain
    thread's rescue absorbs (silently);
  • if it runs during a later example, the double has expired and rspec-mocks raises ExpiredTestDoubleError.

Both MockExpectationErrors are Exceptions, not StandardErrors. The thread dies with one and Ruby
prints the report on $stderr in whichever example is running at that moment. When that example is
inside an output.to_stderr matcher, it fails.

The leak predates this batch (the stubs are from 2026-02 to 2026-10-01). It shows up on Ruby 4.0, which CI
has run since #153: the green run at 1e56e56 printed the report 16 times in test (ubuntu-latest, Ruby 4.0)
and 10 times in deps (latest, Ruby 4.0), and 0 times on Ruby 3.x. Every report names the drain thread
(subprocess_cli_transport.rb:401). The v1.1.0 run on main printed none.

Change

Specs only.

  • The stubs return StringIOs for stdout and stderr, as the #read_messages examples in the same file
    already do. The drain reads EOF and ends whenever it runs, and no rspec object is involved.
  • They connect through a new connect_draining_stderr(transport) helper in spec/support/test_helpers.rb:
    #connect, then a bounded join of the drain thread. If a double comes back, its own example fails
    deterministically instead of an unrelated later one (on Ruby 3.4.5: with the join and the old doubles,
    the 21 examples behind those 22 calls fail with received unexpected message :each_line; with StringIOs, 0).
  • spec/README.md: a convention that no background thread may read an rspec double, and the helper.

The drain thread's rescue StandardError in lib/ stays as it is: MockExpectationError < Exception is
a test-only fact.

Verification

  • Red: with the join and the old doubles, 174 examples, 21 failures on Ruby 3.4.5. Green: with StringIOs,
    the two spec files pass (174 examples, 0 failures) and no thread reports anything.
  • The suite on the locked deps, gemfiles/latest.gemfile and gemfiles/floor.gemfile; rake rbs:test;
    RuboCop: 3574 examples, 0 failures on each of the three; 3206, 0 failures under the RBS runtime checker; no offenses.
  • This PR's CI (run 37108307257, all 12 jobs green): terminated with exception appears 0 times in all 12 job logs, leaked into another example 0 times. At 1e56e56 the same two Ruby 4.0 jobs printed it 16 and 10 times.

Checklist

  • bundle exec rake passes (specs + RuboCop)
  • Specs cover the change (the helper turns a regression into a failure of its own example)
  • CHANGELOG entry: none, the change is test-only
  • Docs updated: spec/README.md
  • No file under lib/ changes

🤖 Generated with Claude Code

https://claude.ai/code/session_01CNgMt2RjeBLyRP3CWFWNy2

Nine Open3.popen3 stubs handed #connect an instance_double(IO) as
stderr. #connect starts a thread that drains stderr, and these examples
never close the transport, so the thread outlived them: run during its
own example, #each_line was an unexpected message; run during a later
one, the double had expired. Both errors are Exceptions, not
StandardErrors, so the thread died and Ruby printed the report on
$stderr in whichever example was running — which failed main's CI once
on Ruby 4.0, inside an `output.to_stderr` matcher of an unrelated
example (spec/unit/subprocess_cli_transport_spec.rb:921, run at
07ad108). Ruby 4.0 runs printed the report 26 times in a green run;
Ruby 3.x runs printed none.

The stubs now return StringIOs for stdout and stderr, as the
#read_messages examples in the same file already do, and connect through
connect_draining_stderr, which joins the drain (bounded): a double put
back fails its own example (21 examples did, with the join and the old
doubles) instead of an unrelated later one. spec/README.md gets the
convention.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNgMt2RjeBLyRP3CWFWNy2
@ya-luotao

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-03T08:03:54.862495Z 815149f Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: 815149f439

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ya-luotao
ya-luotao merged commit 663638a into main Oct 3, 2026
12 checks passed
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