Skip to content

fix: JRuby hangs joining leftover server/browser threads at teardown - #248

Merged
pftg merged 1 commit into
masterfrom
fix/jruby-teardown-hang
Aug 23, 2026
Merged

fix: JRuby hangs joining leftover server/browser threads at teardown#248
pftg merged 1 commit into
masterfrom
fix/jruby-teardown-hang

Conversation

@pftg

@pftg pftg commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closes #244.

The defect

JRuby joins every live Ruby thread at interpreter teardown
(Ruby.tearDown -> ThreadService.teardown -> Thread#join) where MRI just
kills them. Puma's reactor thread parks in a native KQueue.poll /
epoll_wait inside nio4r that no interrupt can wake, so a Capybara-booted
Puma keeps a JRuby process alive forever after an otherwise green run.

Two refinements to the diagnosis in #244:

1. The hang is usually not the suite's own process. The process that
hangs is one of the Open3.capture2e subprocesses — the RSpec fixtures
driven by test/integration/rspec_pending_masking_test.rb and
rspec_after_hook_order_masking_test.rb. Those fixtures load
support/setup_capybara + setup_capybara_drivers, boot Puma and Chrome,
finish their example, and then never exit. The parent blocks in
capture2e, which has no timeout. That is exactly the reported "reached
~230 of 600 tests in 15 minutes" shape: the suite stalls mid-run, not
after it. (The suite process itself has the same leak and would hang at the
end too.)

2. Ferrum is not implicated. Its threads park on Queue#pop, which
JRuby interrupts cleanly at teardown — verified with a standalone probe, and
confirmed by the fix below: the fixture now exits in 7s with its ferrum
threads never quit. The single blocker is the Puma reactor.

jstack evidence (before the fix)

Hung child process, Full thread dump OpenJDK 26.0.2.1:

"main" #3 ... in Object.wait()
   java.lang.Thread.State: TIMED_WAITING (on object monitor)
	at java.lang.Thread.join(java.base@26.0.2.1/Thread.java:1908)
	- locked <0x000000701521bd20> (a java.lang.Thread)
	at org.jruby.internal.runtime.RubyNativeThread.join(RubyNativeThread.java:65)
	at org.jruby.RubyThread.joinCommon(RubyThread.java:1280)
	at org.jruby.RubyThread.join(RubyThread.java:1240)
	at org.jruby.internal.runtime.ThreadService.teardown(ThreadService.java:159)
	at org.jruby.Ruby.systemTeardown(Ruby.java:3376)
	at org.jruby.Ruby.tearDown(Ruby.java:3292)
	at org.jruby.main.Main.internalRun(Main.java:294)

"Ruby-0-Thread-3@puma reactor: .../puma-8.0.2-java/lib/puma/reactor.rb:45" ... runnable
   java.lang.Thread.State: RUNNABLE
	at sun.nio.ch.KQueue.poll(java.base@26.0.2.1/Native Method)
	at sun.nio.ch.KQueueSelectorImpl.doSelect(KQueueSelectorImpl.java:121)
	- locked <0x000000701521be10> (a sun.nio.ch.Util$2)
	- locked <0x000000701521bdb8> (a sun.nio.ch.KQueueSelectorImpl)
	at org.nio4r.Selector.doSelect(Selector.java:239)
	at org.nio4r.Selector.select(Selector.java:197)
	- locked <0x000000701521bd90> (a org.nio4r.Selector)

"Ruby-0-Thread-10: .../ferrum-0.17.2/lib/ferrum/utils/thread.rb:9" ... WAITING (parking)
	at org.jruby.ext.thread.Queue.pop(Queue.java:375)

main stayed in that Thread#join for over two minutes with the suite
already green. The java.lang.Thread it is blocked on (0x…bd20) sits in
the same allocation neighbourhood as the reactor's own selector monitors.
Puma's select_loop additionally wraps the whole loop in
rescue StandardError => e; retry, so even an interruptible exception
would be swallowed and the thread would resume polling.

The fix

Capybara's stock :puma block builds its Puma::Server into a block-local
and calls .run.join — nothing keeps a handle, and Capybara::Server has no
#stop, so nothing anywhere can shut that server down. The harness now
registers an equivalent server block that keeps the handle, and stops the
server for real once the test framework is done.

Puma::Server#stop(true) closes the reactor's input queue and wakes the
selector, which is the only thing that gets that thread out of the native
poll.

Why not the alternatives

  • Capybara.reset_sessions! in an at_exit (the direction guessed in the
    issue): does not help. It calls session.reset! -> driver.reset!; it
    never touches the server, and Capybara exposes no server shutdown at all.
  • Quitting the browser driver: correct hygiene, but not this bug —
    ferrum's threads are Queue#pop-parked and JRuby kills them fine. Adding
    it would grow the diff without changing the outcome.
  • Marking the leftover threads daemon: hides the leak instead of closing
    it, and is not reachable anyway — the reactor thread is created inside
    Puma.
  • A JRuby-only guard: unnecessary. Stopping a server you started is
    correct on every engine; MRI just never punished us for skipping it.

Hook placement

minitest/autorun owns the process exit through its own at_exit,
registered before support/setup_capybara loads — so a plain at_exit
there would fire before the suite runs. The shutdown goes on
Minitest.after_run when minitest is present, and on at_exit for the
standalone RSpec fixture subprocesses, which deliberately avoid
minitest/autorun. Minitest.after_run hooks run in reverse registration
order, so the gem's own Reporting.finalize! still runs before the server
goes away.

Before / after

Deterministic A/B on the fixture that was hanging. JRuby 10.0.6.0,
SCREENSHOT_DRIVER=vips, JRUBY_OPTS=--dev -J-Djruby.thread.pool.enabled=true,
macOS/kqueue, single change being Capybara.server:

Capybara.server wall clock exit
:puma (stock) timeout 120 had to kill it — 2:00.33 124 (hung)
:stoppable_puma (this PR) 7.48s 1 (the fixture's expected failure)

Full bin/rake test on JRuby, four seeds including the one from the issue
(measured at 600 tests, before rebasing onto #246/#247):

seed result wall clock
29927 (the reported seed) 600 runs, 1690 assertions, 0 failures, 0 errors, 1 skip 7m12s
1 600 runs, 0 failures, 0 errors, 1 skip 7m25s
4242 600 runs, 0 failures, 0 errors, 1 skip 8m02s
99999 600 runs, 0 failures, 0 errors, 1 skip 6m53s

Every process exited on its own; none needed a kill, and all four are
comfortably inside the 15-minute per-attempt budget from #243. Before the
fix, seed 29927 stalled at test 294 and jstack showed the teardown join
above.

MRI is unaffected — full bin/rake test on ruby 4.0.6, same machine, only
Capybara.server differing:

Capybara.server result Finished in
:puma (stock) 600 runs, 1690 assertions, 0 failures, 0 errors, 1 skip 81.56s
:stoppable_puma (this PR) 600 runs, 1690 assertions, 0 failures, 0 errors, 1 skip 79.79s

After rebasing onto current master (613 tests), seed 29927 again:
613 runs, 1747 assertions, 0 failures, 0 errors, 1 skip in 7m13s on
JRuby, and 613 runs, 0 failures, 0 errors, 1 skip in 85.37s on MRI.

One earlier post-rebase JRuby run produced 21 Ferrum::DeadBrowserErrors
in BrowserScreenshotTest — Chrome died under load on a busy laptop. It did
not reproduce (the file alone: 21 runs, 0 errors; the full suite at the same
seed: 0 errors) and it is unrelated to this change, which only touches
process exit. Flagging it rather than hiding it.

standardrb: 158 files, no offenses.

Honest verdict

Eliminated for the Puma reactor, which is the thread that was actually
blocking teardown
— and that half is now deterministic rather than
probabilistic: the server is stopped explicitly, so there is no leftover
poll to join. What is not claimed: that no third-party gem can ever leave
another uninterruptible thread behind. Ferrum's threads are still not
quit — they were measured as harmless, not fixed. If a future dependency
parks a thread in native code the same way, the same class of hang can
return.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry @pftg, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@pftg, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 15644ee5-cd78-45c7-9a2b-3ebe77c17967

📥 Commits

Reviewing files that changed from the base of the PR and between 5137f16 and 7592b14.

📒 Files selected for processing (1)
  • test/support/setup_capybara.rb

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

JRuby joins every live Ruby thread at interpreter teardown
(Ruby.tearDown -> ThreadService.teardown -> Thread#join) where MRI just
kills them. Puma's reactor thread parks in a native KQueue.poll/epoll_wait
inside nio4r that no interrupt can wake, so a Capybara-booted Puma keeps a
JRuby process alive forever after an otherwise green run (#244).

The process that actually hangs is usually not the suite's own: it is one
of the Open3.capture2e subprocesses run by the RSpec fixtures, which load
support/setup_capybara, boot Puma and Chrome, finish their example and
never exit -- the parent then blocks in capture2e with no timeout, which
is the reported "stalls mid-run" shape.

Capybara's stock :puma block builds its Puma::Server into a block-local
and joins it, so nothing keeps a handle and Capybara::Server has no #stop.
Register an equivalent block that keeps the handle and stop the server for
real once the framework is done. Puma::Server#stop closes the reactor's
input queue and wakes the selector, which is the only thing that gets that
thread out of the native poll.

Ferrum is not implicated: its threads park on Queue#pop, which JRuby
interrupts cleanly, so no browser quit is needed.
@sourcery-ai

sourcery-ai Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Introduces a custom Capybara server configuration that keeps references to Puma::Server instances so they can be cleanly stopped at test teardown, preventing JRuby from hanging while joining the Puma reactor thread; integrates this shutdown with both Minitest and standalone RSpec fixtures.

File-Level Changes

Change Details Files
Add a stoppable Puma-backed Capybara server and ensure all started servers are stopped at test teardown to avoid JRuby hangs.
  • Require Capybara and Puma in the Capybara setup support file.
  • Define a StoppablePuma module that tracks booted Puma::Server instances and provides a stop_all helper to stop and clear them.
  • Register a :stoppable_puma Capybara server that creates a Puma::Server with specific options, adds the TCP listener, records the server in StoppablePuma, then runs and joins it.
  • Switch Capybara.server from the stock :puma configuration to the new :stoppable_puma server.
  • Add teardown hooks using Minitest.after_run when available, and a plain at_exit otherwise, to call StoppablePuma.stop_all so all servers are stopped before process exit.
test/support/setup_capybara.rb

Assessment against linked issues

Issue Objective Addressed Explanation
#244 Prevent JRuby test processes from hanging during teardown or mid-run because a Capybara-started Puma reactor thread remains alive and blocks on native polling.
#244 Ensure the test harness explicitly shuts down its Capybara/Puma server after the test suite or standalone fixture finishes, while preserving compatibility with both Minitest and standalone RSpec subprocesses.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@pftg
pftg force-pushed the fix/jruby-teardown-hang branch from 6f2d0a3 to 7592b14 Compare August 23, 2026 17:34
@pftg
pftg merged commit 1cd89c9 into master Aug 23, 2026
8 checks passed
@pftg
pftg deleted the fix/jruby-teardown-hang branch August 23, 2026 17:57
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.

JRuby: intermittent seed-dependent hang after the suite finishes (Thread.join on leftover puma/ferrum threads)

1 participant