conformance-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +200 -0
- data/config/rubocop.yml +35 -0
- data/config/rubocop_spinel.yml +16 -0
- data/exe/conformance-audit +6 -0
- data/lib/conformance_kit/audit/allowlist.rb +62 -0
- data/lib/conformance_kit/audit/cli.rb +103 -0
- data/lib/conformance_kit/audit.rb +97 -0
- data/lib/conformance_kit/checks/boot_finalized.rb +62 -0
- data/lib/conformance_kit/checks/consistency.rb +68 -0
- data/lib/conformance_kit/checks/fiber_reactor.rb +50 -0
- data/lib/conformance_kit/checks/ractor_canary.rb +41 -0
- data/lib/conformance_kit/checks/thread_contention.rb +41 -0
- data/lib/conformance_kit/minitest.rb +43 -0
- data/lib/conformance_kit/result.rb +15 -0
- data/lib/conformance_kit/rspec.rb +74 -0
- data/lib/conformance_kit/shareability.rb +116 -0
- data/lib/conformance_kit/store.rb +46 -0
- data/lib/conformance_kit/version.rb +5 -0
- data/lib/conformance_kit.rb +15 -0
- metadata +68 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 45e9d40d8ce04df0a8aae9f4f0bd8da57e2f0f649c2549f1649f7d9d5ed7adc2
|
|
4
|
+
data.tar.gz: df75fb6611c183f7d93d52f95755022f6154afd63cd60a541346e779107e317b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ea8d19b3ca7a5dfdb2a4cd5d5a9e12c350c105ffa7af381319f7d31efc5a10ae8e6e8c35b5ef2f8cb0e70f23f2dcdaa417e8bf96617c9ac51280d176d106272d
|
|
7
|
+
data.tar.gz: aea92dfbc96c976dd0a4a31d556d8d111764c31ddfa14dab32f7f5123aad84272ca59f4251302d160f635af1289de41a357fdae78f474db6ca16a3dc13d2f1e8
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Shared examples (`require "conformance_kit/rspec"`): `"thread-safe under contention"`, `"fiber-safe under an Async reactor"`, `"a boot-finalized configuration"` and `"runs inside a non-main Ractor"` (Ruby 4.0+, tagged `:ractor`).
|
|
6
|
+
- The same checks as Minitest assertions (`require "conformance_kit/minitest"`): `assert_thread_safe`, `assert_fiber_safe`, `assert_boot_finalized`, `assert_runs_in_ractor`.
|
|
7
|
+
- `ConformanceKit::Store.fetch(key) { init }`: per-Ractor lazy storage (`Ractor.store_if_absent` on 3.4+, a Mutex-guarded fallback below).
|
|
8
|
+
- `conformance-audit`: reports unshareable constants and class/module ivars under a namespace; `.conformance.yml` `main_only:` allowlist with a required `reason:`; `--strict` exits 1.
|
|
9
|
+
- `config/rubocop.yml` (rubocop-thread_safety) and `config/rubocop_spinel.yml` (report-only Spinel probe).
|
|
10
|
+
- Reusable workflows: `ractor.yml` (Ruby 4.0 Ractor examples + `conformance-audit --strict`) and `probes.yml` (report-only rubocop_spinel, spinel-compat, roundhouse).
|
|
11
|
+
- Gem skeleton: release kit, labels kit, CI on Ruby 3.2, 3.4 and 4.0.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mhenrixon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# conformance-kit
|
|
2
|
+
|
|
3
|
+
Concurrency conformance for the zoolutions gems. A gem that adopts it is held to explicit tiers, each with a mechanical gate:
|
|
4
|
+
|
|
5
|
+
| Tier | Meaning | Gate |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| 1 thread-safe | no unguarded shared mutable state on the request/broadcast path | `config/rubocop.yml` (rubocop-thread_safety) + `"thread-safe under contention"` |
|
|
8
|
+
| 2 fiber-safe | correct under a fiber scheduler (Falcon/Async): request state fiber-local, no `Thread.main[]` misuse | `"fiber-safe under an Async reactor"` |
|
|
9
|
+
| 3 Ractor-clean | the gem adds no Ractor blocker beyond its dependencies': config shareable after boot, per-Ractor caches, no locks in constants | `"a boot-finalized configuration"`, `conformance-audit --strict`, the Ruby 4.0 `"runs inside a non-main Ractor"` canary (`ractor.yml`) |
|
|
10
|
+
| 4 compiler-probed | Spinel/Roundhouse readiness is measured, never gated | `probes.yml` (report-only) |
|
|
11
|
+
|
|
12
|
+
Background, the finalize-at-boot pattern and the settled decisions: [zoolutions/phlex-reactive#363](https://github.com/zoolutions/phlex-reactive/issues/363).
|
|
13
|
+
|
|
14
|
+
**A development dependency only.** Nothing here becomes a runtime dependency of your gem, and the kit adds none to your bundle: `async`, `minitest`, `rubocop-thread_safety` and `rubocop_spinel` are used only when you already have them. Ruby 3.2+; Ractor-only behaviour is gated on the running Ruby, never on a version floor.
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# Gemfile
|
|
18
|
+
group :development, :test do
|
|
19
|
+
gem "conformance-kit", github: "zoolutions/conformance-kit"
|
|
20
|
+
gem "rubocop-thread_safety", ">= 0.8", require: false # for config/rubocop.yml
|
|
21
|
+
gem "async", require: false # optional: runs the fiber example
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Shared examples (RSpec)
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# spec/spec_helper.rb
|
|
29
|
+
require "conformance_kit/rspec"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Each example calls `subject_call`, a callable for the operation under test:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
RSpec.describe MyGem::Cache do
|
|
36
|
+
let(:subject_call) { -> { MyGem::Cache.for(:key) } }
|
|
37
|
+
|
|
38
|
+
it_behaves_like "thread-safe under contention", threads: 8, iterations: 50, consistent: :identical
|
|
39
|
+
it_behaves_like "fiber-safe under an Async reactor", fibers: 8, iterations: 50
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
RSpec.describe MyGem do
|
|
43
|
+
it_behaves_like "a boot-finalized configuration",
|
|
44
|
+
readers: [MyGem.method(:colors), MyGem.method(:adapters)],
|
|
45
|
+
finalize: -> { MyGem.finalize! }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
RSpec.describe MyGem::Token do
|
|
49
|
+
let(:subject_call) { ->(payload) { MyGem::Token.verify(MyGem::Token.sign(payload)) } }
|
|
50
|
+
|
|
51
|
+
it_behaves_like "runs inside a non-main Ractor", inputs: [{ id: 1 }]
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
| Example | Options | Fails when | Skipped when |
|
|
56
|
+
|---|---|---|---|
|
|
57
|
+
| `"thread-safe under contention"` | `threads:` (8), `iterations:` (50), `consistent:` | a call raises, or the results disagree | — |
|
|
58
|
+
| `"fiber-safe under an Async reactor"` | `fibers:` (8), `iterations:` (50), `consistent:` | a call raises inside an `Async` task, or the results disagree | `async` is not loadable |
|
|
59
|
+
| `"a boot-finalized configuration"` | `readers:`, `finalize:` | a reader returns a value that is not `Ractor.shareable?` (or raises) | no `Ractor` (TruffleRuby, JRuby) |
|
|
60
|
+
| `"runs inside a non-main Ractor"` (tagged `:ractor`) | `inputs:` ([]) | the block raises inside `Ractor.new`, captures outer variables, or returns something else than on the main Ractor | below Ruby 4.0 (`Ractor#value`) |
|
|
61
|
+
|
|
62
|
+
- `consistent:` is opt-in: `:identical` (every result is the same object, e.g. a memoized cache), `:equal` (all `==`), or a callable that receives every result and returns truthy.
|
|
63
|
+
- `readers:` takes `Method` objects (reported as `Owner.reader`), a `{ "name" => callable }` Hash, or bare callables (reported by source location).
|
|
64
|
+
- The Ractor block must be capture-free: pass values through `inputs:`, reach everything else through constants. Before 4.0 a non-main Ractor cannot autoload, so load what it touches first.
|
|
65
|
+
|
|
66
|
+
Failures name the culprit, e.g.
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
a boot-finalized configuration: 1 of 2 readers returned a value that is not Ractor-shareable:
|
|
70
|
+
MyGem.colors returned Hash: [:primary] is an Array that is not frozen: freeze it at boot (Ractor.make_shareable)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Assertions (Minitest)
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
require "minitest/autorun" # the kit never loads Minitest itself
|
|
77
|
+
require "conformance_kit/minitest"
|
|
78
|
+
|
|
79
|
+
class CacheTest < Minitest::Test
|
|
80
|
+
include ConformanceKit::Minitest
|
|
81
|
+
|
|
82
|
+
def test_thread_safe = assert_thread_safe(threads: 8, iterations: 50, consistent: :identical) { MyGem::Cache.for(:key) }
|
|
83
|
+
def test_fiber_safe = assert_fiber_safe(fibers: 8) { MyGem::Cache.for(:key) }
|
|
84
|
+
def test_boot_finalized = assert_boot_finalized(readers: [MyGem.method(:colors)], finalize: -> { MyGem.finalize! })
|
|
85
|
+
def test_ractor = assert_runs_in_ractor(inputs: [2]) { |n| MyGem.double(n) }
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Same checks, same options and messages; skips use `skip` with the reason. Works on Minitest 5 and 6.
|
|
90
|
+
|
|
91
|
+
## `ConformanceKit::Store`
|
|
92
|
+
|
|
93
|
+
Per-Ractor lazy storage, the home for caches that must not live in a class ivar or a constant:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
def self.cache = ConformanceKit::Store.fetch(:my_gem_cache) { {} }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
On Ruby 3.4+ this is `Ractor.store_if_absent` (atomic, one value per Ractor). Below 3.4 the main Ractor (and an engine without `Ractor`) uses one Mutex-guarded Hash, and a non-main Ractor keeps its own values in Ractor-local storage, unlocked: two threads of that one Ractor can race to build a value, as with `||=`. Keys are Symbols; prefix them with your gem's name.
|
|
100
|
+
|
|
101
|
+
## `conformance-audit`
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
bundle exec conformance-audit --require ./lib/my_gem --namespace MyGem --strict
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Requires the files, walks every loaded module under the namespaces (`--require` and `--namespace` repeat), and reports each public constant and class/module instance variable whose value is not `Ractor.shareable?`, with the path to the offending object and the fix:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
conformance-audit: 2 findings in MyGem (1 allowlisted main-only; 14 modules audited, 0 unloaded autoloads skipped)
|
|
111
|
+
MyGem::LOCK (constant) Thread::Mutex: a Thread::Mutex is main-only: keep per-Ractor state in ConformanceKit::Store, or allowlist it main_only
|
|
112
|
+
MyGem::Config.@colors (module ivar) Hash: [:primary] is an Array that is not frozen: freeze it at boot (Ractor.make_shareable)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Limits, so a green run means what it says:
|
|
116
|
+
|
|
117
|
+
- Unloaded autoload constants are skipped and listed, never loaded mid-walk: require (or eager-load) everything the audit should see. The header counts the modules audited and the autoloads skipped.
|
|
118
|
+
- `private_constant`s are invisible to it (Ruby has no public way to enumerate them). `config/rubocop.yml` and the boot-finalized example are the gates for those.
|
|
119
|
+
|
|
120
|
+
| Exit | Meaning |
|
|
121
|
+
|---|---|
|
|
122
|
+
| 0 | no unallowlisted findings; or findings without `--strict` (report only); or no `Ractor` on this engine |
|
|
123
|
+
| 1 | unallowlisted findings under `--strict` |
|
|
124
|
+
| 2 | usage error, a namespace that is not defined or not a module, a file that cannot be required, or an invalid allowlist |
|
|
125
|
+
|
|
126
|
+
### `.conformance.yml`
|
|
127
|
+
|
|
128
|
+
State that is deliberately main-only, read from `.conformance.yml` in the working directory (or `--allowlist PATH`). Every entry needs a `reason:`; an entry without one is an error (exit 2).
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
main_only:
|
|
132
|
+
- name: MyGem::Registry.@lock # class/module ivar: Owner.@ivar
|
|
133
|
+
reason: guards boot-time registration; no non-main Ractor ever registers
|
|
134
|
+
- name: MyGem::LOCK # constant: Owner::NAME
|
|
135
|
+
reason: ...
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Entries inside the audited namespaces that no longer turn up are listed as stale.
|
|
139
|
+
|
|
140
|
+
## RuboCop
|
|
141
|
+
|
|
142
|
+
```yaml
|
|
143
|
+
# .rubocop.yml
|
|
144
|
+
inherit_gem:
|
|
145
|
+
conformance-kit: config/rubocop.yml
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Turns on the six `rubocop-thread_safety` cops (`ClassAndModuleAttributes`, `ClassInstanceVariable`, `MutableClassInstanceVariable` with `EnforcedStyle: literals`, `NewThread`, `DirChdir`, `RackMiddlewareInstanceVariable`) plus `LazySynchronizationPrimitive` (which needs rubocop-thread_safety 0.8+). Exclude a file or line only with a comment saying why it is safe.
|
|
149
|
+
|
|
150
|
+
`config/rubocop_spinel.yml` runs only `Spinel/Unsupported` (rubocop_spinel). It is for the report-only probe, never your lint: it flags `class << self`.
|
|
151
|
+
|
|
152
|
+
## Reusable workflows
|
|
153
|
+
|
|
154
|
+
```yaml
|
|
155
|
+
# .github/workflows/main.yml
|
|
156
|
+
jobs:
|
|
157
|
+
ractor:
|
|
158
|
+
uses: zoolutions/conformance-kit/.github/workflows/ractor.yml@main
|
|
159
|
+
with:
|
|
160
|
+
require: ./lib/my_gem
|
|
161
|
+
namespace: MyGem
|
|
162
|
+
|
|
163
|
+
probes:
|
|
164
|
+
uses: zoolutions/conformance-kit/.github/workflows/probes.yml@main
|
|
165
|
+
with:
|
|
166
|
+
gem-name: my_gem
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**`ractor.yml`** (the Tier 3 gate): the Ractor examples, then `conformance-audit --strict`.
|
|
170
|
+
|
|
171
|
+
| Input | Default | |
|
|
172
|
+
|---|---|---|
|
|
173
|
+
| `require` | required | files the audit requires (space-separated) |
|
|
174
|
+
| `namespace` | required | constants to audit (space-separated) |
|
|
175
|
+
| `ruby-version` | `4.0` | |
|
|
176
|
+
| `allowlist` | `.conformance.yml` | skipped when the default file is absent |
|
|
177
|
+
| `test-command` | `bundle exec rspec --tag ractor` | Minitest gems pass their own |
|
|
178
|
+
| `audit` | `true` | `false` runs only the examples |
|
|
179
|
+
| `working-directory` | `.` | |
|
|
180
|
+
|
|
181
|
+
**`probes.yml`** (Tier 4, report-only): `rubocop_spinel`, `spinel-compat probe <gem-name> --dir .` and, when `roundhouse-app` is set, `roundhouse check`. Every probe step continues on error; the reports go to the run summary and an artifact. It never fails a build.
|
|
182
|
+
|
|
183
|
+
| Input | Default | |
|
|
184
|
+
|---|---|---|
|
|
185
|
+
| `gem-name` | required | the gem spinel-compat probes from the checkout |
|
|
186
|
+
| `ruby-version` | `4.0` | |
|
|
187
|
+
| `paths` | `lib` | what rubocop_spinel inspects (space-separated) |
|
|
188
|
+
| `roundhouse-app` | `""` | a Rails app directory (e.g. `spec/dummy`); empty skips it |
|
|
189
|
+
| `roundhouse-version` | `v2026.9.18` | the rubys/roundhouse release to install |
|
|
190
|
+
| `roundhouse-sha256` | SHA-256 of that release's `roundhouse-x86_64-unknown-linux-musl.tar.xz` | checked before the binary runs; change it with `roundhouse-version` |
|
|
191
|
+
| `artifact-name` | `conformance-probes` | |
|
|
192
|
+
| `working-directory` | `.` | |
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
bundle exec rake # rspec + rubocop; CI runs Ruby 3.2, 3.4 and 4.0
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Every check is proven both ways against `spec/fixtures/`: a violating fixture it must catch and a conforming one it must pass. The kit audits itself (`.conformance.yml`) and calls its own workflows in CI.
|
data/config/rubocop.yml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# conformance-kit's Tier 1 RuboCop gate. In a consuming gem's .rubocop.yml:
|
|
2
|
+
#
|
|
3
|
+
# inherit_gem:
|
|
4
|
+
# conformance-kit: config/rubocop.yml
|
|
5
|
+
#
|
|
6
|
+
# and add `gem "rubocop-thread_safety", require: false` to its Gemfile (the kit
|
|
7
|
+
# never adds dependencies for you). Exclude a file or line only with a comment
|
|
8
|
+
# saying why it is safe, e.g. a Mutex created at load and only used on the main
|
|
9
|
+
# Ractor, or the specs that start threads on purpose.
|
|
10
|
+
plugins:
|
|
11
|
+
- rubocop-thread_safety
|
|
12
|
+
|
|
13
|
+
# The six cops of the epic's Tier 1, on whatever the consumer's NewCops is.
|
|
14
|
+
ThreadSafety/ClassAndModuleAttributes:
|
|
15
|
+
Enabled: true
|
|
16
|
+
|
|
17
|
+
ThreadSafety/ClassInstanceVariable:
|
|
18
|
+
Enabled: true
|
|
19
|
+
|
|
20
|
+
ThreadSafety/MutableClassInstanceVariable:
|
|
21
|
+
Enabled: true
|
|
22
|
+
EnforcedStyle: literals
|
|
23
|
+
|
|
24
|
+
ThreadSafety/NewThread:
|
|
25
|
+
Enabled: true
|
|
26
|
+
|
|
27
|
+
ThreadSafety/DirChdir:
|
|
28
|
+
Enabled: true
|
|
29
|
+
|
|
30
|
+
ThreadSafety/RackMiddlewareInstanceVariable:
|
|
31
|
+
Enabled: true
|
|
32
|
+
|
|
33
|
+
# `@mutex ||= Mutex.new` is itself a race: two threads can build two locks.
|
|
34
|
+
ThreadSafety/LazySynchronizationPrimitive:
|
|
35
|
+
Enabled: true
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# conformance-kit's report-only Spinel probe (Tier 4): only the Spinel cops,
|
|
2
|
+
# never a gate. Run by .github/workflows/probes.yml as
|
|
3
|
+
#
|
|
4
|
+
# rubocop --config <conformance-kit>/config/rubocop_spinel.yml lib
|
|
5
|
+
#
|
|
6
|
+
# It flags `class << self`, which every zoolutions gem uses: read the report,
|
|
7
|
+
# don't chase it.
|
|
8
|
+
plugins:
|
|
9
|
+
- rubocop_spinel
|
|
10
|
+
|
|
11
|
+
AllCops:
|
|
12
|
+
DisabledByDefault: true
|
|
13
|
+
SuggestExtensions: false
|
|
14
|
+
|
|
15
|
+
Spinel/Unsupported:
|
|
16
|
+
Enabled: true
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module ConformanceKit
|
|
6
|
+
class Audit
|
|
7
|
+
class AllowlistError < StandardError; end
|
|
8
|
+
|
|
9
|
+
# `.conformance.yml`: state that is deliberately main-only, each entry with
|
|
10
|
+
# the reason it is safe.
|
|
11
|
+
#
|
|
12
|
+
# main_only:
|
|
13
|
+
# - name: MyGem::Registry.@lock # a class/module ivar: Owner.@ivar
|
|
14
|
+
# reason: guards boot-time registration; never touched by a non-main Ractor
|
|
15
|
+
# - name: MyGem::LOCK # a constant: Owner::NAME
|
|
16
|
+
# reason: ...
|
|
17
|
+
class Allowlist
|
|
18
|
+
def self.empty = new([])
|
|
19
|
+
|
|
20
|
+
def self.load(path)
|
|
21
|
+
data = YAML.safe_load_file(path) || {}
|
|
22
|
+
raise AllowlistError, "#{path}: expected a mapping with a main_only: list" unless data.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
new(data.fetch("main_only", []) || [], source: path)
|
|
25
|
+
rescue Psych::Exception => e
|
|
26
|
+
raise AllowlistError, "#{path}: #{e.message}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :entries
|
|
30
|
+
|
|
31
|
+
def initialize(entries, source: "the allowlist")
|
|
32
|
+
raise AllowlistError, "#{source}: main_only: must be a list" unless entries.is_a?(Array)
|
|
33
|
+
|
|
34
|
+
@entries = entries.map { validate(_1, source) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def names = entries.map { _1["name"] }
|
|
38
|
+
def include?(name) = names.include?(name)
|
|
39
|
+
def reason_for(name) = entries.find { _1["name"] == name }&.fetch("reason")
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def validate(entry, source)
|
|
44
|
+
unless entry.is_a?(Hash)
|
|
45
|
+
raise AllowlistError,
|
|
46
|
+
"#{source}: each main_only entry needs a name: and a reason:, got #{entry.inspect}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
name = entry["name"]
|
|
50
|
+
raise AllowlistError, "#{source}: a main_only entry has no name: (#{entry.inspect})" if blank?(name)
|
|
51
|
+
if blank?(entry["reason"])
|
|
52
|
+
raise AllowlistError,
|
|
53
|
+
"#{source}: #{name} has no reason: (say why it is safe main-only)"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
entry
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def blank?(value) = !value.is_a?(String) || value.strip.empty?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module ConformanceKit
|
|
6
|
+
class Audit
|
|
7
|
+
# `conformance-audit --require FILE --namespace CONST [--strict] [--allowlist PATH]`
|
|
8
|
+
#
|
|
9
|
+
# Exit codes: 0 clean (or report-only, or no Ractor on this engine),
|
|
10
|
+
# 1 unallowlisted findings under --strict, 2 usage or allowlist error.
|
|
11
|
+
class CLI
|
|
12
|
+
DEFAULT_ALLOWLIST = ".conformance.yml"
|
|
13
|
+
USAGE_ERROR = 2
|
|
14
|
+
|
|
15
|
+
def self.run(argv, out: $stdout, err: $stderr) = new(out, err).run(argv)
|
|
16
|
+
|
|
17
|
+
def initialize(out, err)
|
|
18
|
+
@out = out
|
|
19
|
+
@err = err
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run(argv)
|
|
23
|
+
options = parse(argv)
|
|
24
|
+
return unavailable unless defined?(::Ractor)
|
|
25
|
+
|
|
26
|
+
options[:requires].each { require File.expand_path(_1) }
|
|
27
|
+
options[:namespaces].each { check_namespace(_1) }
|
|
28
|
+
audit = Audit.new(namespaces: options[:namespaces], allowlist: allowlist(options[:allowlist]))
|
|
29
|
+
report(audit, options[:strict])
|
|
30
|
+
rescue OptionParser::ParseError, AllowlistError, LoadError, NameError, ArgumentError => e
|
|
31
|
+
@err.puts "conformance-audit: #{e.message}"
|
|
32
|
+
USAGE_ERROR
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def parse(argv)
|
|
38
|
+
options = { requires: [], namespaces: [], strict: false, allowlist: nil }
|
|
39
|
+
parser(options).parse!(argv.dup)
|
|
40
|
+
raise OptionParser::MissingArgument, "--namespace (the constant to audit)" if options[:namespaces].empty?
|
|
41
|
+
|
|
42
|
+
options
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parser(options)
|
|
46
|
+
OptionParser.new do |o|
|
|
47
|
+
o.banner = "Usage: conformance-audit --require FILE --namespace CONST [--strict] [--allowlist PATH]"
|
|
48
|
+
o.on("--require FILE", "Require FILE before auditing (repeatable)") { options[:requires] << _1 }
|
|
49
|
+
o.on("--namespace CONST", "Audit CONST and everything under it (repeatable)") do |name|
|
|
50
|
+
options[:namespaces] << name.delete_prefix("::")
|
|
51
|
+
end
|
|
52
|
+
o.on("--strict", "Exit 1 on any unallowlisted finding") { options[:strict] = true }
|
|
53
|
+
o.on("--allowlist PATH", "main_only: allowlist (default: #{DEFAULT_ALLOWLIST} if present)") do |path|
|
|
54
|
+
options[:allowlist] = path
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def check_namespace(name)
|
|
60
|
+
value = Object.const_get(name)
|
|
61
|
+
raise ArgumentError, "--namespace #{name} is a #{value.class}, not a module" unless value.is_a?(Module)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def allowlist(path)
|
|
65
|
+
return Allowlist.load(path) if path
|
|
66
|
+
return Allowlist.load(DEFAULT_ALLOWLIST) if File.exist?(DEFAULT_ALLOWLIST)
|
|
67
|
+
|
|
68
|
+
Allowlist.empty
|
|
69
|
+
rescue Errno::ENOENT
|
|
70
|
+
raise AllowlistError, "allowlist #{path} does not exist"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def unavailable
|
|
74
|
+
@out.puts "conformance-audit: Ractor is unavailable on #{RUBY_ENGINE} #{RUBY_VERSION}; nothing to audit."
|
|
75
|
+
0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def report(audit, strict)
|
|
79
|
+
findings = audit.findings
|
|
80
|
+
allowlisted = audit.allowlisted
|
|
81
|
+
@out.puts "conformance-audit: #{findings.size} findings in #{audit.namespaces.join(', ')} " \
|
|
82
|
+
"(#{allowlisted.size} allowlisted main-only; #{audit.modules_audited} modules audited, " \
|
|
83
|
+
"#{audit.skipped_autoloads.size} unloaded autoloads skipped)"
|
|
84
|
+
findings.each { @out.puts " #{_1}" }
|
|
85
|
+
section("allowlisted main-only:", allowlisted.map { "#{_1.name} — #{audit.allowlist.reason_for(_1.name)}" })
|
|
86
|
+
section("stale allowlist entries (no longer found):", audit.stale)
|
|
87
|
+
section("unloaded autoloads, not audited (require or eager-load them):", audit.skipped_autoloads)
|
|
88
|
+
return 0 if findings.empty?
|
|
89
|
+
return 1 if strict
|
|
90
|
+
|
|
91
|
+
@out.puts "report only: --strict fails on these"
|
|
92
|
+
0
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def section(title, lines)
|
|
96
|
+
return if lines.empty?
|
|
97
|
+
|
|
98
|
+
@out.puts title
|
|
99
|
+
lines.each { @out.puts " #{_1}" }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "conformance_kit"
|
|
4
|
+
require_relative "audit/allowlist"
|
|
5
|
+
require_relative "audit/cli"
|
|
6
|
+
|
|
7
|
+
module ConformanceKit
|
|
8
|
+
# Walks every loaded module under the given namespaces and reports each
|
|
9
|
+
# constant and class/module instance variable whose value is not
|
|
10
|
+
# Ractor-shareable. Unloaded autoload constants are skipped: require (or
|
|
11
|
+
# eager-load) everything the audit should see.
|
|
12
|
+
class Audit
|
|
13
|
+
Finding = Data.define(:name, :kind, :value_class, :path, :reason) do
|
|
14
|
+
def to_s
|
|
15
|
+
where = path.empty? ? "" : " #{path} is"
|
|
16
|
+
"#{name} (#{kind}) #{value_class}:#{where} #{reason}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_reader :namespaces, :allowlist
|
|
21
|
+
|
|
22
|
+
def initialize(namespaces:, allowlist: Allowlist.empty)
|
|
23
|
+
@namespaces = namespaces
|
|
24
|
+
@allowlist = allowlist
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def findings = partitioned.first
|
|
28
|
+
def allowlisted = partitioned.last
|
|
29
|
+
|
|
30
|
+
def modules_audited = audited_modules.size
|
|
31
|
+
|
|
32
|
+
# Constants declared with `autoload` but never loaded: not audited.
|
|
33
|
+
def skipped_autoloads
|
|
34
|
+
audited_modules.flat_map do |mod, name|
|
|
35
|
+
constants_of(mod).select { autoload?(mod, _1) }.map { "#{name}::#{_1}" }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Allowlisted names inside the audited namespaces that no longer turn up.
|
|
40
|
+
def stale
|
|
41
|
+
found = scan.map(&:name)
|
|
42
|
+
@allowlist.names.select { in_namespace?(_1) && !found.include?(_1) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def partitioned
|
|
48
|
+
@partitioned ||= scan.partition { !@allowlist.include?(_1.name) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def scan
|
|
52
|
+
@scan ||= audited_modules.flat_map { |mod, name| constant_findings(mod, name) + ivar_findings(mod, name) }
|
|
53
|
+
.sort_by(&:name)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def audited_modules
|
|
57
|
+
@audited_modules ||= ObjectSpace.each_object(Module).filter_map do |mod|
|
|
58
|
+
next if mod.singleton_class?
|
|
59
|
+
|
|
60
|
+
name = ::Module.instance_method(:name).bind_call(mod) # a class may override .name
|
|
61
|
+
[mod, name] if name && in_namespace?(name)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def in_namespace?(name)
|
|
66
|
+
namespaces.any? { |namespace| name == namespace || name.start_with?("#{namespace}::", "#{namespace}.") }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def constant_findings(mod, name)
|
|
70
|
+
constants_of(mod).filter_map do |constant|
|
|
71
|
+
next if autoload?(mod, constant)
|
|
72
|
+
|
|
73
|
+
finding("#{name}::#{constant}", "constant", core(::Module, :const_get, mod, constant, false))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def ivar_findings(mod, name)
|
|
78
|
+
core(::Kernel, :instance_variables, mod).filter_map do |ivar|
|
|
79
|
+
kind = mod.is_a?(Class) ? "class ivar" : "module ivar"
|
|
80
|
+
finding("#{name}.#{ivar}", kind, core(::Kernel, :instance_variable_get, mod, ivar))
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The core reflection methods, bound directly: a module may override its own
|
|
85
|
+
# `.constants` or `.instance_variables` and hide state from the audit.
|
|
86
|
+
def core(owner, method, receiver, *) = owner.instance_method(method).bind_call(receiver, *)
|
|
87
|
+
def constants_of(mod) = core(::Module, :constants, mod, false)
|
|
88
|
+
def autoload?(mod, constant) = core(::Module, :autoload?, mod, constant)
|
|
89
|
+
|
|
90
|
+
def finding(name, kind, value)
|
|
91
|
+
culprit = Shareability.culprit(value)
|
|
92
|
+
return nil unless culprit
|
|
93
|
+
|
|
94
|
+
Finding.new(name: name, kind: kind, value_class: value.class.name, path: culprit.path, reason: culprit.reason)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConformanceKit
|
|
4
|
+
module Checks
|
|
5
|
+
# "a boot-finalized configuration": after `finalize`, every reader returns a
|
|
6
|
+
# Ractor-shareable value. Readers are `Method` objects (named
|
|
7
|
+
# `Owner.reader`), a `{ "name" => callable }` Hash, or bare callables (named
|
|
8
|
+
# by source location).
|
|
9
|
+
module BootFinalized
|
|
10
|
+
NAME = "a boot-finalized configuration"
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def call(readers:, finalize: nil)
|
|
14
|
+
raise ArgumentError, "readers: is empty, so #{NAME} would check nothing" if readers.nil? || readers.empty?
|
|
15
|
+
|
|
16
|
+
unless Shareability.available?
|
|
17
|
+
return Result.skip("#{NAME} needs Ractor.shareable?, which #{RUBY_ENGINE} #{RUBY_VERSION} lacks")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
finalize&.call
|
|
21
|
+
named = name_readers(readers)
|
|
22
|
+
offenses = named.filter_map { |name, reader| offense(name, reader) }
|
|
23
|
+
return Result.pass if offenses.empty?
|
|
24
|
+
|
|
25
|
+
Result.fail("#{NAME}: #{offenses.size} of #{named.size} readers returned a value " \
|
|
26
|
+
"that is not Ractor-shareable:\n#{offenses.map { " #{_1}" }.join("\n")}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def offense(name, reader)
|
|
32
|
+
value = reader.call
|
|
33
|
+
culprit = Shareability.culprit(value)
|
|
34
|
+
return nil unless culprit
|
|
35
|
+
|
|
36
|
+
where = culprit.path.empty? ? "it is" : "#{culprit.path} is"
|
|
37
|
+
"#{name} returned #{value.class}: #{where} #{culprit.reason}"
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
"#{name} raised #{e.class}: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def name_readers(readers)
|
|
43
|
+
return readers.map { |name, reader| [name.to_s, reader] } if readers.is_a?(::Hash)
|
|
44
|
+
|
|
45
|
+
Array(readers).map { |reader| [reader_name(reader), reader] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reader_name(reader)
|
|
49
|
+
case reader
|
|
50
|
+
when ::Method
|
|
51
|
+
receiver = reader.receiver
|
|
52
|
+
receiver.is_a?(::Module) ? "#{receiver.name}.#{reader.name}" : "#{receiver.class.name}##{reader.name}"
|
|
53
|
+
when ::Proc
|
|
54
|
+
file, line = reader.source_location
|
|
55
|
+
"the reader at #{file}:#{line}"
|
|
56
|
+
else reader.inspect
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConformanceKit
|
|
4
|
+
module Checks
|
|
5
|
+
# The outcome reporting shared by the thread and fiber checks: exceptions,
|
|
6
|
+
# and the `consistent:` option.
|
|
7
|
+
module Consistency
|
|
8
|
+
MODES = %i[identical equal].freeze
|
|
9
|
+
# How many distinct results a failure report tells apart before it stops counting.
|
|
10
|
+
DIAGNOSED = 10
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def validate_counts!(**counts)
|
|
14
|
+
counts.each do |name, count|
|
|
15
|
+
next if count.is_a?(Integer) && count.positive?
|
|
16
|
+
|
|
17
|
+
raise ArgumentError, "#{name}: must be a positive Integer (got #{count.inspect}); " \
|
|
18
|
+
"zero would pass without a single call"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def validate!(consistent)
|
|
23
|
+
return if consistent.nil? || consistent.respond_to?(:call) || MODES.include?(consistent)
|
|
24
|
+
|
|
25
|
+
raise ArgumentError, "consistent: must be :identical, :equal or a callable taking every result, " \
|
|
26
|
+
"got #{consistent.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# nil when consistent, a failure description otherwise.
|
|
30
|
+
def problem(results, consistent)
|
|
31
|
+
case consistent
|
|
32
|
+
when nil then nil
|
|
33
|
+
when :identical then distinct(results.uniq(&:__id__), consistent)
|
|
34
|
+
when :equal then distinct(uniq_by_equality(results), consistent)
|
|
35
|
+
else "the `consistent:` check returned false for #{results.size} results" unless consistent.call(results)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def raised(errors, total, unit)
|
|
40
|
+
first = errors.first
|
|
41
|
+
where = first.backtrace ? " (#{first.backtrace.first})" : ""
|
|
42
|
+
"#{errors.size} of #{total} #{unit} raised; first: #{first.class}: #{first.message}#{where}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Array#uniq compares with eql?/hash (1 and 1.0 differ); :equal promises ==. Stops past
|
|
48
|
+
# DIAGNOSED distinct values, so N different results cost O(N) == calls, not O(N²).
|
|
49
|
+
def uniq_by_equality(results)
|
|
50
|
+
results.each_with_object([]) do |result, seen|
|
|
51
|
+
next if seen.any? { |s| s == result }
|
|
52
|
+
|
|
53
|
+
seen << result
|
|
54
|
+
break seen if seen.size > DIAGNOSED
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def distinct(unique, mode)
|
|
59
|
+
return nil if unique.size <= 1
|
|
60
|
+
|
|
61
|
+
count = unique.size > DIAGNOSED ? "more than #{DIAGNOSED}" : unique.size
|
|
62
|
+
classes = unique.group_by { _1.class.name }.map { |name, all| "#{name} ×#{all.size}" }.join(", ")
|
|
63
|
+
"returned #{count} distinct results (#{classes}), so the `consistent: #{mode.inspect}` check failed"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConformanceKit
|
|
4
|
+
module Checks
|
|
5
|
+
# "fiber-safe under an Async reactor": `fibers` Async tasks, each making
|
|
6
|
+
# `iterations` calls. Needs the `async` gem, which is never a dependency of
|
|
7
|
+
# the kit: skipped with a reason when it cannot be loaded.
|
|
8
|
+
module FiberReactor
|
|
9
|
+
NAME = "fiber-safe under an Async reactor"
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def call(fibers: 8, iterations: 50, consistent: nil, loader: -> { require "async" }, &subject)
|
|
13
|
+
Consistency.validate_counts!(fibers: fibers, iterations: iterations)
|
|
14
|
+
Consistency.validate!(consistent)
|
|
15
|
+
begin
|
|
16
|
+
loader.call
|
|
17
|
+
rescue LoadError => e
|
|
18
|
+
return Result.skip("#{NAME} needs the async gem, which is not loadable (#{e.message}): " \
|
|
19
|
+
"add `gem \"async\"` to the Gemfile's test group to run it")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
verdict(run(fibers, iterations, subject), fibers, iterations, consistent)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def run(fibers, iterations, subject)
|
|
28
|
+
Async do |task|
|
|
29
|
+
tasks = Array.new(fibers) do
|
|
30
|
+
task.async do
|
|
31
|
+
Array.new(iterations) { subject.call }
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
e
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
tasks.map(&:wait)
|
|
37
|
+
end.wait
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def verdict(outcomes, fibers, iterations, consistent)
|
|
41
|
+
errors = outcomes.grep(Exception)
|
|
42
|
+
return Result.fail("#{NAME}: #{Consistency.raised(errors, fibers, 'fibers')}") if errors.any?
|
|
43
|
+
|
|
44
|
+
problem = Consistency.problem(outcomes.flatten(1), consistent)
|
|
45
|
+
problem ? Result.fail("#{NAME}: #{fibers} fibers × #{iterations} calls #{problem}") : Result.pass
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConformanceKit
|
|
4
|
+
module Checks
|
|
5
|
+
# "runs inside a non-main Ractor": runs a capture-free block on the main
|
|
6
|
+
# Ractor and inside `Ractor.new(*inputs)`, and compares the two results.
|
|
7
|
+
# Ruby 4.0+ only (`Ractor#value`); skipped with a reason below.
|
|
8
|
+
module RactorCanary
|
|
9
|
+
NAME = "runs inside a non-main Ractor"
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def call(inputs: [], &block)
|
|
13
|
+
return Result.skip(unavailable) unless available?
|
|
14
|
+
|
|
15
|
+
ractor = ::Ractor.new(*inputs, &block) # copies the inputs before the main run can mutate them
|
|
16
|
+
expected = block.call(*inputs)
|
|
17
|
+
actual = ractor.value
|
|
18
|
+
return Result.pass if actual == expected
|
|
19
|
+
|
|
20
|
+
Result.fail("#{NAME}: returned #{actual.inspect} inside the Ractor " \
|
|
21
|
+
"but #{expected.inspect} on the main Ractor")
|
|
22
|
+
rescue ::Ractor::RemoteError => e
|
|
23
|
+
cause = e.cause || e
|
|
24
|
+
Result.fail("#{NAME}: raised #{cause.class} inside the Ractor: #{cause.message}")
|
|
25
|
+
rescue ::Ractor::IsolationError, ::ArgumentError => e
|
|
26
|
+
raise unless e.message.include?("isolate")
|
|
27
|
+
|
|
28
|
+
Result.fail("#{NAME}: the block must be capture-free (pass values through inputs:): #{e.message}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def available? = defined?(::Ractor) && ::Ractor.method_defined?(:value)
|
|
34
|
+
|
|
35
|
+
def unavailable
|
|
36
|
+
"#{NAME} needs Ruby 4.0+ (Ractor#value); running #{RUBY_ENGINE} #{RUBY_VERSION}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConformanceKit
|
|
4
|
+
module Checks
|
|
5
|
+
# "thread-safe under contention": `threads` threads released together, each
|
|
6
|
+
# making `iterations` calls. Fails on any exception, or when the results
|
|
7
|
+
# disagree under `consistent:`.
|
|
8
|
+
module ThreadContention
|
|
9
|
+
NAME = "thread-safe under contention"
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def call(threads: 8, iterations: 50, consistent: nil, &subject)
|
|
13
|
+
Consistency.validate_counts!(threads: threads, iterations: iterations)
|
|
14
|
+
Consistency.validate!(consistent)
|
|
15
|
+
outcomes = run(threads, iterations, subject)
|
|
16
|
+
errors = outcomes.grep(Exception)
|
|
17
|
+
return Result.fail("#{NAME}: #{Consistency.raised(errors, threads, 'threads')}") if errors.any?
|
|
18
|
+
|
|
19
|
+
problem = Consistency.problem(outcomes.flatten(1), consistent)
|
|
20
|
+
problem ? Result.fail("#{NAME}: #{threads} threads × #{iterations} calls #{problem}") : Result.pass
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def run(threads, iterations, subject)
|
|
26
|
+
start = Queue.new
|
|
27
|
+
workers = Array.new(threads) do
|
|
28
|
+
Thread.new do # rubocop:disable ThreadSafety/NewThread -- contention is the point of the check
|
|
29
|
+
start.pop
|
|
30
|
+
Array.new(iterations) { subject.call }
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
e
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
threads.times { start << true }
|
|
36
|
+
workers.map(&:value)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "conformance_kit"
|
|
4
|
+
|
|
5
|
+
module ConformanceKit
|
|
6
|
+
# The Minitest front end: the same four checks as assertions.
|
|
7
|
+
#
|
|
8
|
+
# class CacheTest < Minitest::Test
|
|
9
|
+
# include ConformanceKit::Minitest
|
|
10
|
+
#
|
|
11
|
+
# def test_cache_is_thread_safe
|
|
12
|
+
# assert_thread_safe(threads: 8, iterations: 50, consistent: :identical) { MyGem.cache_for(:key) }
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Uses only `assert` and `skip`, so it works on Minitest 5 and 6.
|
|
17
|
+
module Minitest
|
|
18
|
+
def assert_thread_safe(threads: 8, iterations: 50, consistent: nil, &)
|
|
19
|
+
conformance_enforce(Checks::ThreadContention.call(
|
|
20
|
+
threads: threads, iterations: iterations, consistent: consistent, &
|
|
21
|
+
))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def assert_fiber_safe(fibers: 8, iterations: 50, consistent: nil, &)
|
|
25
|
+
conformance_enforce(Checks::FiberReactor.call(fibers: fibers, iterations: iterations, consistent: consistent, &))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def assert_boot_finalized(readers:, finalize: nil)
|
|
29
|
+
conformance_enforce(Checks::BootFinalized.call(readers: readers, finalize: finalize))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def assert_runs_in_ractor(inputs: [], &)
|
|
33
|
+
conformance_enforce(Checks::RactorCanary.call(inputs: inputs, &))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def conformance_enforce(result)
|
|
39
|
+
skip(result.message) if result.skipped?
|
|
40
|
+
assert(result.passed?, result.message)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConformanceKit
|
|
4
|
+
# What a check returns; the RSpec and Minitest front ends turn it into a
|
|
5
|
+
# pass, a failure or a skip.
|
|
6
|
+
Result = Data.define(:status, :message) do
|
|
7
|
+
def self.pass(message = "ok") = new(status: :passed, message: message)
|
|
8
|
+
def self.fail(message) = new(status: :failed, message: message)
|
|
9
|
+
def self.skip(message) = new(status: :skipped, message: message)
|
|
10
|
+
|
|
11
|
+
def passed? = status == :passed
|
|
12
|
+
def failed? = status == :failed
|
|
13
|
+
def skipped? = status == :skipped
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
require "conformance_kit"
|
|
5
|
+
|
|
6
|
+
module ConformanceKit
|
|
7
|
+
# The RSpec front end: `require "conformance_kit/rspec"` registers four shared
|
|
8
|
+
# examples. Each example group defines `subject_call`, a callable for the
|
|
9
|
+
# operation under test (not needed by "a boot-finalized configuration").
|
|
10
|
+
#
|
|
11
|
+
# let(:subject_call) { -> { MyGem.cache_for(:key) } }
|
|
12
|
+
# it_behaves_like "thread-safe under contention", threads: 8, iterations: 50, consistent: :identical
|
|
13
|
+
module RSpec
|
|
14
|
+
class << self
|
|
15
|
+
def register!
|
|
16
|
+
register_thread_example
|
|
17
|
+
register_fiber_example
|
|
18
|
+
register_boot_example
|
|
19
|
+
register_ractor_example
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def enforce(example, result)
|
|
23
|
+
return if result.passed?
|
|
24
|
+
return example.skip(result.message) if result.skipped?
|
|
25
|
+
|
|
26
|
+
raise failure_class, result.message
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def failure_class
|
|
32
|
+
return RuntimeError unless defined?(::RSpec::Expectations::ExpectationNotMetError)
|
|
33
|
+
|
|
34
|
+
::RSpec::Expectations::ExpectationNotMetError
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def register_thread_example
|
|
38
|
+
::RSpec.shared_examples "thread-safe under contention" do |threads: 8, iterations: 50, consistent: nil|
|
|
39
|
+
it "survives #{threads} threads × #{iterations} calls" do
|
|
40
|
+
options = { threads: threads, iterations: iterations, consistent: consistent }
|
|
41
|
+
ConformanceKit::RSpec.enforce(self, Checks::ThreadContention.call(**options, &subject_call))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def register_fiber_example
|
|
47
|
+
::RSpec.shared_examples "fiber-safe under an Async reactor" do |fibers: 8, iterations: 50, consistent: nil|
|
|
48
|
+
it "survives #{fibers} fibers × #{iterations} calls in one reactor" do
|
|
49
|
+
options = { fibers: fibers, iterations: iterations, consistent: consistent }
|
|
50
|
+
ConformanceKit::RSpec.enforce(self, Checks::FiberReactor.call(**options, &subject_call))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def register_boot_example
|
|
56
|
+
::RSpec.shared_examples "a boot-finalized configuration" do |readers:, finalize: nil|
|
|
57
|
+
it "returns a Ractor-shareable value from every reader after finalize" do
|
|
58
|
+
ConformanceKit::RSpec.enforce(self, Checks::BootFinalized.call(readers: readers, finalize: finalize))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def register_ractor_example
|
|
64
|
+
::RSpec.shared_examples "runs inside a non-main Ractor" do |inputs: []|
|
|
65
|
+
it "returns the main Ractor's result from inside Ractor.new", :ractor do
|
|
66
|
+
ConformanceKit::RSpec.enforce(self, Checks::RactorCanary.call(inputs: inputs, &subject_call))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
ConformanceKit::RSpec.register!
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConformanceKit
|
|
4
|
+
# Finds WHY a value is not Ractor-shareable: the first offending object on a
|
|
5
|
+
# depth-first walk (followed as deep as it goes), the path to it, and the fix.
|
|
6
|
+
# Works on any value, BasicObject included: it only calls core methods.
|
|
7
|
+
module Shareability
|
|
8
|
+
Culprit = Data.define(:path, :object_class, :reason)
|
|
9
|
+
|
|
10
|
+
LOCKS = %w[Thread::Mutex Monitor Thread::Queue Thread::SizedQueue Thread::ConditionVariable].freeze
|
|
11
|
+
MAX_DEPTH = 32
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def available? = defined?(::Ractor) ? ::Ractor.respond_to?(:shareable?) : false
|
|
15
|
+
|
|
16
|
+
# nil when the value is shareable, a Culprit otherwise.
|
|
17
|
+
def culprit(value)
|
|
18
|
+
return nil if ::Ractor.shareable?(value)
|
|
19
|
+
|
|
20
|
+
find(value, "", {}, 0)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def find(value, path, seen, depth)
|
|
26
|
+
return nil if ::Ractor.shareable?(value) || seen.key?(value.__id__)
|
|
27
|
+
return blame(value, path) if intrinsic?(value) || depth >= MAX_DEPTH
|
|
28
|
+
|
|
29
|
+
seen[value.__id__] = true
|
|
30
|
+
# Freezing an unfrozen container would not fix a lock or a Proc inside it.
|
|
31
|
+
return intrinsic_below(value, path, seen, depth) || blame(value, path) unless frozen?(value)
|
|
32
|
+
|
|
33
|
+
children(value).each do |child_path, child|
|
|
34
|
+
found = find(child, path + child_path, seen, depth + 1)
|
|
35
|
+
return found if found
|
|
36
|
+
end
|
|
37
|
+
blame(value, path)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def intrinsic?(value) = callable?(value) || lock?(value) || concurrent?(value)
|
|
41
|
+
|
|
42
|
+
# The first never-shareable object (lock, Proc, Concurrent::*) under value.
|
|
43
|
+
def intrinsic_below(value, path, seen, depth)
|
|
44
|
+
return nil if depth >= MAX_DEPTH
|
|
45
|
+
|
|
46
|
+
children(value).each do |child_path, child|
|
|
47
|
+
next if ::Ractor.shareable?(child) || seen.key?(child.__id__)
|
|
48
|
+
return blame(child, path + child_path) if intrinsic?(child)
|
|
49
|
+
|
|
50
|
+
seen[child.__id__] = true
|
|
51
|
+
found = intrinsic_below(child, path + child_path, seen, depth + 1)
|
|
52
|
+
return found if found
|
|
53
|
+
end
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def children(value)
|
|
58
|
+
pairs = core(:instance_variables, value).map { |ivar| [".#{ivar}", core(:instance_variable_get, value, ivar)] }
|
|
59
|
+
case value
|
|
60
|
+
when ::Hash then pairs + hash_children(value)
|
|
61
|
+
when ::Array then pairs + value.each_with_index.map { |v, i| ["[#{i}]", v] }
|
|
62
|
+
when ::Struct, ::Data then pairs + value.to_h.map { |k, v| [".#{k}", v] }
|
|
63
|
+
when ::Range then pairs + [[".begin", value.begin], [".end", value.end]]
|
|
64
|
+
else pairs
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def hash_children(hash)
|
|
69
|
+
pairs = hash.flat_map { |k, v| [["{key #{label(k)}}", k], ["[#{label(k)}]", v]] }
|
|
70
|
+
return pairs + [[".default_proc", hash.default_proc]] if hash.default_proc
|
|
71
|
+
|
|
72
|
+
hash.default.nil? ? pairs : pairs + [[".default", hash.default]]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def blame(value, path)
|
|
76
|
+
Culprit.new(path: path, object_class: class_name(value), reason: reason(value))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def reason(value)
|
|
80
|
+
name = class_name(value)
|
|
81
|
+
if callable?(value)
|
|
82
|
+
"#{article(name)} #{name} is never shareable: use Ractor.shareable_proc (Ruby 4.0) " \
|
|
83
|
+
"or Ractor.make_shareable on a capture-free proc"
|
|
84
|
+
elsif lock?(value) || concurrent?(value)
|
|
85
|
+
"#{article(name)} #{name} is main-only: keep per-Ractor state in ConformanceKit::Store, " \
|
|
86
|
+
"or allowlist it main_only"
|
|
87
|
+
elsif !frozen?(value)
|
|
88
|
+
"#{article(name)} #{name} that is not frozen: freeze it at boot (Ractor.make_shareable)"
|
|
89
|
+
else
|
|
90
|
+
"#{article(name)} #{name} that is frozen but not shareable (an extension object, or unshareable internals)"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def article(name) = name.match?(/\A[AEIOU]/) ? "an" : "a"
|
|
95
|
+
|
|
96
|
+
# Module#=== rather than value.is_a?, which a BasicObject lacks.
|
|
97
|
+
def callable?(value)
|
|
98
|
+
[::Proc, ::Method, ::UnboundMethod].any? { _1 === value } # rubocop:disable Style/CaseEquality
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def frozen?(value) = core(:frozen?, value)
|
|
102
|
+
def core(method, value, *) = ::Kernel.instance_method(method).bind_call(value, *)
|
|
103
|
+
|
|
104
|
+
# A key's #inspect is consumer code: never let it abort the walk.
|
|
105
|
+
def label(key)
|
|
106
|
+
key.inspect
|
|
107
|
+
rescue StandardError
|
|
108
|
+
"#<#{class_name(key)}>"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def lock?(value) = LOCKS.include?(class_name(value))
|
|
112
|
+
def concurrent?(value) = class_name(value).start_with?("Concurrent::")
|
|
113
|
+
def class_name(value) = ::Kernel.instance_method(:class).bind_call(value).name || "anonymous class"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConformanceKit
|
|
4
|
+
# Per-Ractor lazy storage: `Store.fetch(:my_gem_cache) { {} }`.
|
|
5
|
+
#
|
|
6
|
+
# On Ruby 3.4+ this is `Ractor.store_if_absent`, atomic and per-Ractor. Below
|
|
7
|
+
# 3.4 (and on engines without Ractor) the main Ractor uses one Hash guarded by
|
|
8
|
+
# a Mutex, created at load (never lazily, so there is no check-then-set race);
|
|
9
|
+
# it is the kit's one allowlisted main-only state (.conformance.yml). A
|
|
10
|
+
# non-main Ractor on 3.2/3.3 cannot read it, so it keeps its own values in
|
|
11
|
+
# Ractor-local storage instead (not locked: one Ractor's threads may race to
|
|
12
|
+
# build a value, as with any `||=`).
|
|
13
|
+
module Store
|
|
14
|
+
# rubocop:disable ThreadSafety/MutableClassInstanceVariable -- main-only fallback, guarded by @fallback_lock
|
|
15
|
+
@fallback_lock = Mutex.new
|
|
16
|
+
@fallback_values = {}
|
|
17
|
+
# rubocop:enable ThreadSafety/MutableClassInstanceVariable
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def fetch(key, &init)
|
|
21
|
+
raise ArgumentError, "ConformanceKit::Store.fetch(#{key.inspect}) needs an initializer block" unless init
|
|
22
|
+
return ::Ractor.store_if_absent(key, &init) if native?
|
|
23
|
+
return ractor_local(key, &init) unless main_ractor?
|
|
24
|
+
|
|
25
|
+
@fallback_lock.synchronize do
|
|
26
|
+
@fallback_values.fetch(key) { @fallback_values[key] = init.call }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def main_ractor? = !defined?(::Ractor) || ::Ractor.current == ::Ractor.main
|
|
33
|
+
|
|
34
|
+
# Boxed, so a nil or false value is cached rather than rebuilt on every fetch.
|
|
35
|
+
def ractor_local(key)
|
|
36
|
+
(::Ractor.current[:"conformance_kit_store/#{key}"] ||= [yield]).first
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
public
|
|
40
|
+
|
|
41
|
+
def native?
|
|
42
|
+
defined?(::Ractor) ? ::Ractor.respond_to?(:store_if_absent) : false
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "conformance_kit/version"
|
|
4
|
+
require_relative "conformance_kit/result"
|
|
5
|
+
require_relative "conformance_kit/store"
|
|
6
|
+
require_relative "conformance_kit/shareability"
|
|
7
|
+
require_relative "conformance_kit/checks/consistency"
|
|
8
|
+
require_relative "conformance_kit/checks/thread_contention"
|
|
9
|
+
require_relative "conformance_kit/checks/fiber_reactor"
|
|
10
|
+
require_relative "conformance_kit/checks/boot_finalized"
|
|
11
|
+
require_relative "conformance_kit/checks/ractor_canary"
|
|
12
|
+
|
|
13
|
+
# Concurrency conformance for the zoolutions gems. See README.md for the tiers.
|
|
14
|
+
module ConformanceKit
|
|
15
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: conformance-kit
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- mhenrixon
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Shared RSpec examples, a per-Ractor storage shim, a class-state audit,
|
|
13
|
+
RuboCop configs and reusable CI workflows that hold a gem to explicit thread-safe,
|
|
14
|
+
fiber-safe and Ractor-clean tiers.
|
|
15
|
+
email:
|
|
16
|
+
- mikael@mhenrixon.com
|
|
17
|
+
executables:
|
|
18
|
+
- conformance-audit
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- CHANGELOG.md
|
|
23
|
+
- LICENSE.txt
|
|
24
|
+
- README.md
|
|
25
|
+
- config/rubocop.yml
|
|
26
|
+
- config/rubocop_spinel.yml
|
|
27
|
+
- exe/conformance-audit
|
|
28
|
+
- lib/conformance_kit.rb
|
|
29
|
+
- lib/conformance_kit/audit.rb
|
|
30
|
+
- lib/conformance_kit/audit/allowlist.rb
|
|
31
|
+
- lib/conformance_kit/audit/cli.rb
|
|
32
|
+
- lib/conformance_kit/checks/boot_finalized.rb
|
|
33
|
+
- lib/conformance_kit/checks/consistency.rb
|
|
34
|
+
- lib/conformance_kit/checks/fiber_reactor.rb
|
|
35
|
+
- lib/conformance_kit/checks/ractor_canary.rb
|
|
36
|
+
- lib/conformance_kit/checks/thread_contention.rb
|
|
37
|
+
- lib/conformance_kit/minitest.rb
|
|
38
|
+
- lib/conformance_kit/result.rb
|
|
39
|
+
- lib/conformance_kit/rspec.rb
|
|
40
|
+
- lib/conformance_kit/shareability.rb
|
|
41
|
+
- lib/conformance_kit/store.rb
|
|
42
|
+
- lib/conformance_kit/version.rb
|
|
43
|
+
homepage: https://github.com/zoolutions/conformance-kit
|
|
44
|
+
licenses:
|
|
45
|
+
- MIT
|
|
46
|
+
metadata:
|
|
47
|
+
source_code_uri: https://github.com/zoolutions/conformance-kit
|
|
48
|
+
changelog_uri: https://github.com/zoolutions/conformance-kit/blob/main/CHANGELOG.md
|
|
49
|
+
rubygems_mfa_required: 'true'
|
|
50
|
+
rdoc_options: []
|
|
51
|
+
require_paths:
|
|
52
|
+
- lib
|
|
53
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: '3.2'
|
|
58
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '0'
|
|
63
|
+
requirements: []
|
|
64
|
+
rubygems_version: 4.0.19
|
|
65
|
+
specification_version: 4
|
|
66
|
+
summary: 'Concurrency conformance gates for Ruby gems: threads, fibers, Ractors and
|
|
67
|
+
compiler probes.'
|
|
68
|
+
test_files: []
|