Skip to content

Fix flaky specs on TruffleRuby - #597

Open
andrykonchin wants to merge 2 commits into
guard:masterfrom
andrykonchin:ak/fix-flaky-specs-on-truffleruby
Open

Fix flaky specs on TruffleRuby#597
andrykonchin wants to merge 2 commits into
guard:masterfrom
andrykonchin:ak/fix-flaky-specs-on-truffleruby

Conversation

@andrykonchin

@andrykonchin andrykonchin commented Jul 17, 2026

Copy link
Copy Markdown

What/Why

CI fails sporadically on TruffleRuby because background threads are stopped asynchronously, causing different test cases to interfere with one another.

cc @eregon

The Root Cause

In the listen gem, when an adapter is stopped via stop, it invokes the private _stop method. In Listen::Adapter::Base and Listen::Adapter::Darwin, _stop terminates background adapter threads (like @run_thread and @worker_thread) using Thread#kill without waiting for them to die. However, Thread#kill is asynchronous. In Ruby, target threads do not terminate instantaneously when kill is called.

The Race Condition

In adapter/linux_spec.rb, a real polling adapter is started, which spawns the background @run_thread using a mock silencer double. After the test runs, subject.stop is called to kill the thread, and the test immediately concludes. Once the test concludes, RSpec enters its teardown phase and destroys/resets the mock doubles. Because the background thread was not joined, it was still running (navigating directories or building initial state) while the next test files loaded and started executing (e.g., event/queue_spec.rb). When the leaked thread executed the next check on the (now deregistered/destroyed) silencer double, RSpec raised an unexpected message error (#<InstanceDouble(Listen::Silencer) "silencer"> received unexpected message :silenced? with ("", :dir)).

Why it aborted the whole suite

The test helper in spec/spec_helper.rb sets Thread.abort_on_exception= true. Therefore, when the mock error propagated out of the background thread as an unhandled exception, it immediately aborted the main thread during event/queue_spec.rb, causing that test to fail.

Failed CI job example

Exception in `adapter/linux_spec.rb`
Listen::Adapter::Linux
  class methods
    is expected to be usable
  instance methods
    _callback
      recognizes moved_to as moved_to
      recognizes moved_from as moved_from
      recognizes close_write as modify
    #stop
      when configured
        stops the worker
      when not even initialized
        does not crash
    inotify max watches exceeded
      raises exception
    watch events
      starts by calling watch with default events

Listen::Event::Queue
#<Thread:0x24e88@listen-run_thread /home/runner/work/listen/listen/lib/listen/thread.rb:17 run> terminated with exception (report_on_exception is true):
  #<<
/home/runner/work/listen/listen/vendor/bundle/truffleruby/4.0.2.6/gems/rspec-support-3.13.7/lib/rspec/support.rb:110:in 'block in <module:Support>': #<InstanceDouble(Listen::Silencer) "silencer"> received unexpected message :silenced? with ("", :dir) (RSpec::Mocks::MockExpectationError)
	from /home/runner/work/listen/listen/vendor/bundle/truffleruby/4.0.2.6/gems/rspec-support-3.13.7/lib/rspec/support.rb:119:in 'RSpec::Support.notify_failure'
	from /home/runner/work/listen/listen/vendor/bundle/truffleruby/4.0.2.6/gems/rspec-mocks-3.13.8/lib/rspec/mocks/error_generator.rb:348:in 'RSpec::Mocks::ErrorGenerator#notify'
	from /home/runner/work/listen/listen/vendor/bundle/truffleruby/4.0.2.6/gems/rspec-mocks-3.13.8/lib/rspec/mocks/error_generator.rb:332:in 'RSpec::Mocks::ErrorGenerator#__raise'
	from /home/runner/work/listen/listen/vendor/bundle/truffleruby/4.0.2.6/gems/rspec-mocks-3.13.8/lib/rspec/mocks/error_generator.rb:50:in 'RSpec::Mocks::ErrorGenerator#raise_unexpected_message_error'
	from /home/runner/work/listen/listen/vendor/bundle/truffleruby/4.0.2.6/gems/rspec-mocks-3.13.8/lib/rspec/mocks/proxy.rb:229:in 'RSpec::Mocks::Proxy#raise_unexpected_message_error'
	from /home/runner/work/listen/listen/vendor/bundle/truffleruby/4.0.2.6/gems/rspec-mocks-3.13.8/lib/rspec/mocks/test_double.rb:105:in 'RSpec::Mocks::TestDouble#method_missing'
	from /home/runner/work/listen/listen/vendor/bundle/truffleruby/4.0.2.6/gems/rspec-mocks-3.13.8/lib/rspec/mocks/verifying_double.rb:34:in 'RSpec::Mocks::VerifyingDouble#method_missing'
	from /home/runner/work/listen/listen/lib/listen/record.rb:104:in 'Listen::Record#_fast_build_dir'
	from /home/runner/work/listen/listen/lib/listen/record.rb:69:in 'Listen::Record#build'
	from /home/runner/work/listen/listen/lib/listen/adapter/base.rb:77:in 'block (3 levels) in Listen::Adapter::Base#start'
	from /home/runner/work/listen/listen/lib/listen/adapter/base.rb:97:in 'Listen::Adapter::Base#_timed'
	from /home/runner/work/listen/listen/lib/listen/adapter/base.rb:77:in 'block (2 levels) in Listen::Adapter::Base#start'
	from <internal:core> core/hash.rb:424:in 'block in Hash#each_value'
	from <internal:core> core/hash.rb:424:in 'Hash#each_pair'
	from <internal:core> core/hash.rb:424:in 'Hash#each_value'
	from /home/runner/work/listen/listen/lib/listen/adapter/base.rb:76:in 'block in Listen::Adapter::Base#start'
	from /home/runner/work/listen/listen/lib/listen/thread.rb:26:in 'Listen::Thread.rescue_and_log'
	from /home/runner/work/listen/listen/lib/listen/thread.rb:18:in 'block in Listen::Thread.new'
    when relative option is true
      when watched dir is not the current dir
        registers relative path (FAILED - 1)
Failing `event/queue_spec.rb`
Failures:

  1) Listen::Event::Queue#<< when relative option is true when watched dir is not the current dir registers relative path
     Failure/Error: return if @silencer.silenced?(entry.record_dir_key, :dir)
       #<InstanceDouble(Listen::Silencer) "silencer"> received unexpected message :silenced? with ("", :dir)
     # ./lib/listen/record.rb:104:in 'Listen::Record#_fast_build_dir'
     # ./lib/listen/record.rb:69:in 'Listen::Record#build'
     # ./lib/listen/adapter/base.rb:77:in 'block (3 levels) in Listen::Adapter::Base#start'
     # ./lib/listen/adapter/base.rb:97:in 'Listen::Adapter::Base#_timed'
     # ./lib/listen/adapter/base.rb:77:in 'block (2 levels) in Listen::Adapter::Base#start'
     # ./lib/listen/adapter/base.rb:76:in 'block in Listen::Adapter::Base#start'
     # ./lib/listen/thread.rb:26:in 'Listen::Thread.rescue_and_log'
     # ./lib/listen/thread.rb:18:in 'block in Listen::Thread.new'

https://github.com/andrykonchin/listen/actions/runs/29610077425/job/87982425598?pr=1


def _stop
@run_thread&.kill
@run_thread.kill.join if @run_thread

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Style/SafeNavigation: Use safe navigation (&.) instead of checking if an object exists before calling the method.


def _stop
@worker_thread&.kill
@worker_thread.kill.join if @worker_thread

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Style/SafeNavigation: Use safe navigation (&.) instead of checking if an object exists before calling the method.

@LouisaNikita

LouisaNikita commented Jul 17, 2026 via email

Copy link
Copy Markdown

@eregon eregon left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nice, LGTM

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.

4 participants