Skip to content

Commit 18c9641

Browse files
pftgclaude
andcommitted
Fix thread safety issues found in review
- Eagerly initialize reporters_mutex to prevent race on lazy init - Move @Finalized flag after write_report so finalize can retry on failure - Use mutex-protected snapshot in at_exit hook for consistency - Fix THREAD_SAFETY.md: use proper Ruby class names, document eager mutex - Add real multi-threaded concurrency test (10 threads x 5 assertions) - Add finalize retry-after-failure test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3b564f5 commit 18c9641

5 files changed

Lines changed: 90 additions & 73 deletions

File tree

docs/thread_safety.md

Lines changed: 42 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,43 @@
1-
# thread safety guide for parallel testing
1+
# Thread Safety Guide for Parallel Testing
22

3-
this document explains how `snap_diff` behaves under rails parallel tests with the `:thread` strategy.
3+
This document explains how `snap_diff` behaves under Rails parallel tests with the `:thread` strategy.
44

5-
## overview
5+
## Overview
66

7-
`snap_diff` is thread safe for parallel test execution as long as global configuration is set before tests run. per thread state is isolated, and shared state is protected where it matters.
7+
`snap_diff` is thread safe for parallel test execution as long as global configuration is set before tests run. Per-thread state is isolated, and shared state is protected where it matters.
88

9-
## architecture summary
9+
## Architecture Summary
1010

11-
### per-thread assertion registry
11+
### Per-thread Assertion Registry
1212

13-
each thread gets its own `assertionregistry` stored in thread-local storage:
13+
Each thread gets its own `AssertionRegistry` stored in thread-local storage:
1414

1515
```ruby
1616
def registry
17-
thread.current[:capybara_screenshot_diff_registry] ||= assertionregistry.new
17+
Thread.current[:capybara_screenshot_diff_registry] ||= AssertionRegistry.new
1818
end
1919
```
2020

21-
this prevents cross-thread leakage for assertions and screenshot naming.
21+
This prevents cross-thread leakage for assertions and screenshot naming.
2222

23-
### reporters snapshot on notify
23+
### Reporters Snapshot on Notify
2424

25-
reporters are notified using a snapshot protected by a mutex:
25+
Reporters are notified using a snapshot protected by an eagerly initialized mutex:
2626

2727
```ruby
28+
@reporters_mutex = Mutex.new
29+
2830
def notify_reporters(assertions)
2931
reporters_snapshot = reporters_mutex.synchronize { reporters.dup }
3032
reporters_snapshot.each { |reporter| reporter.record(assertions) }
3133
end
3234
```
3335

34-
this ensures a stable list while notifying without forcing a global lock around reporter work.
36+
This ensures a stable list while notifying without forcing a global lock around reporter work.
3537

36-
### html reporter internal lock
38+
### HTML Reporter Internal Lock
3739

38-
the html reporter protects `@failures`, `@total`, and `@finalized` with a mutex so record and finalize can run safely:
40+
The HTML reporter protects `@failures`, `@total`, and `@finalized` with a mutex so `record` and `finalize` can run safely:
3941

4042
```ruby
4143
@mutex.synchronize do
@@ -45,49 +47,51 @@ the html reporter protects `@failures`, `@total`, and `@finalized` with a mutex
4547
end
4648
```
4749

48-
### screenshot naming isolation
50+
`@finalized` is set only after `write_report` succeeds, so a failed write can be retried.
51+
52+
### Screenshot Naming Isolation
4953

50-
each thread gets its own `screenshotnamer` via the per-thread registry, so counters, sections, and groups do not collide.
54+
Each thread gets its own `ScreenshotNamer` via the per-thread registry, so counters, sections, and groups do not collide.
5155

52-
### snapshot manager per call
56+
### SnapManager Per Call
5357

54-
`snapmanager.instance` returns a new instance for each call, avoiding shared mutable state.
58+
`SnapManager` returns a new instance for each call, avoiding shared mutable state.
5559

56-
## global configuration
60+
## Global Configuration
5761

58-
configuration uses `mattr_accessor` and should be set once before tests run. do not mutate config during parallel execution.
62+
Configuration uses `mattr_accessor` and should be set once before tests run. Do not mutate config during parallel execution.
5963

60-
## parallel test lifecycle
64+
## Parallel Test Lifecycle
6165

62-
- setup: per-thread registry is created, config is read
63-
- execution: assertions are added to the thread-local registry
64-
- teardown: verify and reset operate on the thread-local registry, reporters are notified
65-
- exit: reporters finalize once per process
66+
- Setup: per-thread registry is created, config is read
67+
- Execution: assertions are added to the thread-local registry
68+
- Teardown: `verify` and `reset` operate on the thread-local registry, reporters are notified
69+
- Exit: reporters finalize once per process (using mutex-protected snapshot)
6670

67-
## usage examples
71+
## Usage Examples
6872

6973
```ruby
7074
parallelize(workers: :number_of_processors, with: :threads)
7175

72-
capybara::screenshot::diff.configure do |screenshot, diff|
76+
Capybara::Screenshot::Diff.configure do |screenshot, diff|
7377
screenshot.window_size = [1280, 1024]
7478
screenshot.save_path = "doc/screenshots"
7579
diff.tolerance = 0.001
7680
end
7781
```
7882

79-
## do and do not
83+
## Do and Do Not
8084

81-
do:
82-
- set config once in test helper
83-
- pass per-screenshot options in the call
85+
Do:
86+
- Set config once in test helper
87+
- Pass per-screenshot options in the call
8488

85-
do not:
86-
- change global config inside tests
87-
- manually mutate registry internals
89+
Do not:
90+
- Change global config inside tests
91+
- Manually mutate registry internals
8892

89-
## file system notes
93+
## File System Notes
9094

91-
- paths are unique per screenshot name and counter
92-
- `fileutils.mv` is atomic on most file systems
93-
- directory creation uses `mkpath`
95+
- Paths are unique per screenshot name and counter
96+
- `FileUtils.mv` is atomic on most file systems
97+
- Directory creation uses `mkpath`

lib/capybara_screenshot_diff/reporters/html.rb

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,10 @@ def record(assertions)
4949
def finalize
5050
@mutex.synchronize do
5151
return if @finalized
52-
53-
@finalized = true
5452
return if failures.empty?
5553

5654
write_report
55+
@finalized = true
5756
output_path
5857
end
5958
end
@@ -122,7 +121,7 @@ def write_report
122121
end
123122

124123
at_exit do
125-
CapybaraScreenshotDiff.reporters.each do |reporter|
124+
CapybaraScreenshotDiff.reporters_mutex.synchronize { CapybaraScreenshotDiff.reporters.dup }.each do |reporter|
126125
result = reporter.finalize
127126
$stdout.puts "[snap_diff] HTML report: #{result}" if result.is_a?(Pathname)
128127
rescue => e

lib/capybara_screenshot_diff/screenshot_assertion.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,7 @@ def reporters
133133
@reporters ||= []
134134
end
135135

136-
def reporters_mutex
137-
@reporters_mutex ||= Mutex.new
138-
end
136+
attr_reader :reporters_mutex
139137

140138
def_delegator :registry, :screenshot_namer
141139
def_delegator :registry, :verify
@@ -155,4 +153,6 @@ def notify_reporters(assertions)
155153
end
156154
end
157155
end
156+
157+
@reporters_mutex = Mutex.new
158158
end

test/unit/reporters/html_reporter_test.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,22 @@ class HTMLReporterTest < ActiveSupport::TestCase
9898
assert_includes html, "data:image/png;base64,"
9999
end
100100

101+
test "#record from multiple threads produces correct totals" do
102+
reporter = HTML.new(output_path: @output_path)
103+
threads = 10
104+
assertions_per_thread = 5
105+
106+
workers = threads.times.map do
107+
Thread.new do
108+
batch = assertions_per_thread.times.map { |i| build_passing_assertion("t#{Thread.current.object_id}_#{i}") }
109+
reporter.record(batch)
110+
end
111+
end
112+
workers.each(&:join)
113+
114+
assert_equal threads * assertions_per_thread, reporter.total
115+
end
116+
101117
test "#record and #finalize synchronize internal state" do
102118
reporter = HTML.new(output_path: @output_path)
103119

@@ -124,6 +140,28 @@ def synchronize
124140
assert_operator fake_mutex.synchronize_calls, :>=, 1
125141
end
126142

143+
test "#finalize can retry after write_report failure" do
144+
reporter = HTML.new(output_path: @output_path)
145+
reporter.record([build_failing_assertion("retry")])
146+
147+
# Make write_report fail on first attempt
148+
FileUtils.rm_rf(@output_dir)
149+
File.write(@output_dir.to_s, "not a directory")
150+
151+
begin
152+
reporter.finalize
153+
rescue # rubocop:disable Lint/SuppressedException
154+
end
155+
156+
# Restore writable directory and retry
157+
File.delete(@output_dir.to_s)
158+
FileUtils.mkdir_p(@output_dir)
159+
160+
result = reporter.finalize
161+
assert_instance_of Pathname, result
162+
assert @output_path.exist?
163+
end
164+
127165
private
128166

129167
def build_passing_assertion(name)

test/unit/reporters_mutex_test.rb

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,38 +12,14 @@ class ReportersMutexTest < ActiveSupport::TestCase
1212
teardown do
1313
CapybaraScreenshotDiff.reporters.clear
1414
CapybaraScreenshotDiff.reporters.concat(@original_reporters)
15-
CapybaraScreenshotDiff.instance_variable_set(:@reporters_mutex, nil)
1615
end
1716

18-
test "reporters notification uses mutex snapshot" do
19-
fake_mutex = Class.new do
20-
attr_reader :synchronize_calls
21-
22-
def initialize
23-
@synchronize_calls = 0
24-
end
25-
26-
def synchronize
27-
@synchronize_calls += 1
28-
yield
29-
end
30-
end.new
31-
32-
CapybaraScreenshotDiff.instance_variable_set(:@reporters_mutex, fake_mutex)
33-
34-
reporter = Class.new do
35-
def record(_assertions); end
36-
end.new
37-
38-
CapybaraScreenshotDiff.reporters << reporter
39-
40-
assertion = CapybaraScreenshotDiff::ScreenshotAssertion.new("sample")
41-
assertion.compare = Object.new
42-
CapybaraScreenshotDiff.add_assertion(assertion)
43-
44-
CapybaraScreenshotDiff.reset
17+
test "reporters_mutex is eagerly initialized" do
18+
assert_instance_of Mutex, CapybaraScreenshotDiff.reporters_mutex
19+
end
4520

46-
assert_operator fake_mutex.synchronize_calls, :>=, 1
21+
test "reporters_mutex returns the same instance" do
22+
assert_same CapybaraScreenshotDiff.reporters_mutex, CapybaraScreenshotDiff.reporters_mutex
4723
end
4824

4925
test "reporters notification iterates over snapshot" do

0 commit comments

Comments
 (0)