conformance-kit 0.4.2 → 0.5.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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +169 -4
- data/config/ractorize.gemfile +25 -0
- data/lib/conformance_kit/audit/cli.rb +2 -2
- data/lib/conformance_kit/audit.rb +1 -1
- data/lib/conformance_kit/cli/ractorize_check.rb +63 -0
- data/lib/conformance_kit/cli.rb +14 -4
- data/lib/conformance_kit/config.rb +40 -4
- data/lib/conformance_kit/ractorize/boot.rb +9 -0
- data/lib/conformance_kit/ractorize/request.rb +60 -0
- data/lib/conformance_kit/ractorize/runner.rb +131 -0
- data/lib/conformance_kit/ractorize.rb +11 -0
- data/lib/conformance_kit/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5a02143b58331881bf17f8f3fa4af58ed4a2e31293ecf91ec36c75dc6b88cf76
|
|
4
|
+
data.tar.gz: ccf2d81afca57e37ac74347deb18baba5a08a1ee592d2ec10580cdee0aecba6b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 379a96c84f23be361de24dc24e55f66c313e00290b3fbe47b9b54fd4848f0ea02051aa3dc4c9186cf1f363d3584711e12c10a90f43e9ac0a4d37a9646d55bc6c
|
|
7
|
+
data.tar.gz: 8d4e996635a3e7b4b1d9b8d086d058c57fa6c16207b8f0d2a989aed19c9ed6ea9e22cdb0af000ebd178b99e3537704f5c30eb9aa0f929f9b24c1fa42ceb585d4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 - 2026-10-18
|
|
4
|
+
|
|
5
|
+
- `conformance-audit --require` and `audit.require` take a gem or feature name as well as a path: an entry that is not written as a path (`./`, `../`, `~`, absolute) and names no existing file is required from the load path. The kit now audits any installed gem with no Gemfile change and no `.conformance.yml`: `gem install conformance-kit` then `conformance-audit --require loofah --namespace Loofah`. Existing paths resolve as before.
|
|
6
|
+
- README: how to try the audit on any gem, and a Minitest and an RSpec conformance case (the entry point in a non-main Ractor; every constant shareable) for gems that won't take the dependency.
|
|
7
|
+
- The ractorize job (report-only, #22): `conformance-kit check --ractorize` boots `ractorize.app` in production with `ActiveSupport::Ractors.unshareable_proc_action = :raise`, calls `Rails.application.ractorize!`, serves each `ractorize.requests` path inside a non-main Ractor (a failure names the root cause and its first frame, also when an error page swallowed it) and audits `ractorize.audit_namespaces` (default `audit.namespace`) in the same process. It skips with the reason below Ruby 4.0 or on a Rails without `ractorize!`. New `.conformance.yml` keys: `ractorize.app`, `ractorize.requests`, `ractorize.audit_namespaces`, `ractorize.setup`.
|
|
8
|
+
- `conformance.yml` gains a Ractorize job that runs when `ractorize.app` is set. It is `continue-on-error` until Rails 8.2 ships `ractorize!`, and bundles the repo with `config/ractorize.gemfile`: Rails main, Rack main and Shopify's i18n with Shopify/i18n#5, replacing the repo's own lines for them.
|
|
9
|
+
|
|
3
10
|
## 0.4.2 - 2026-10-17
|
|
4
11
|
|
|
5
12
|
- A Proc or lock found inside another value now reads as one sentence: `[:save] is a Proc that is never shareable: ...` and `.@lock is a Thread::Mutex that is main-only: ...` instead of `is a Proc is never shareable` (in `conformance-audit` findings and `"a boot-finalized configuration"` failures; a top-level one reads `a Proc that is never shareable`).
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# conformance-kit
|
|
2
2
|
|
|
3
|
-
Concurrency conformance for the zoolutions gems. A gem that adopts it is held to explicit tiers, each with a mechanical gate:
|
|
3
|
+
Concurrency conformance for Ruby gems. Built for the zoolutions gems and usable by any gem: [audit a gem without adding anything to it](#try-it-on-any-gem), [copy the checks into your own tests](#a-conformance-case-without-the-kit), or adopt the kit. A gem that adopts it is held to explicit tiers, each with a mechanical gate:
|
|
4
4
|
|
|
5
5
|
| Tier | Meaning | Gate |
|
|
6
6
|
|---|---|---|
|
|
@@ -11,6 +11,102 @@ Concurrency conformance for the zoolutions gems. A gem that adopts it is held to
|
|
|
11
11
|
|
|
12
12
|
Background, the finalize-at-boot pattern and the settled decisions: [zoolutions/phlex-reactive#363](https://github.com/zoolutions/phlex-reactive/issues/363).
|
|
13
13
|
|
|
14
|
+
## Try it on any gem
|
|
15
|
+
|
|
16
|
+
No Gemfile change and no `.conformance.yml`: install the kit, then name the gem to load and the namespace to audit.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
gem install conformance-kit
|
|
20
|
+
conformance-audit --require loofah --namespace Loofah
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
conformance-audit: 42 findings in Loofah (0 allowlisted main-only; 38 modules audited, 0 unloaded autoloads skipped)
|
|
25
|
+
Loofah::Elements::BLOCK_LEVEL (constant) Set: a Set that is not frozen: freeze it at boot (Ractor.make_shareable)
|
|
26
|
+
...
|
|
27
|
+
Loofah::Scrubbers::MAP (constant) Hash: a Hash that is not frozen: freeze it at boot (Ractor.make_shareable)
|
|
28
|
+
report only: --strict fails on these
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
That is loofah 2.25.2 on Ruby 4.0.7: every finding is state a non-main Ractor cannot read. `--require` takes a gem or feature name (required from the load path, so any installed gem) or a file path, and repeats (`--require rack --require rack/mock_request`). Without `--strict` it only reports. What it can and can't see, and its exit codes: [`conformance-audit`](#conformance-audit).
|
|
32
|
+
|
|
33
|
+
## A conformance case without the kit
|
|
34
|
+
|
|
35
|
+
When a new development dependency is not an option (a framework, a gem that keeps its bundle small), the two checks that matter most fit in the gem's own tests. This is the shape of the case to attach to a compatibility pull request: it fails before the fix, passes after it, and fails again on the next constant that stops being shareable.
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# Minitest
|
|
39
|
+
require "minitest/autorun" # or your test_helper
|
|
40
|
+
|
|
41
|
+
class RactorConformanceTest < Minitest::Test
|
|
42
|
+
# State that is main-only on purpose, each with why.
|
|
43
|
+
MAIN_ONLY = { "MyGem::REGISTRY" => "apps add to it at boot" }.freeze
|
|
44
|
+
|
|
45
|
+
def setup
|
|
46
|
+
skip "needs Ruby 4.0 (Ractor#value)" unless defined?(Ractor) && Ractor.method_defined?(:value)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The entry point runs inside a non-main Ractor and gives the main Ractor's answer.
|
|
50
|
+
def test_runs_inside_a_non_main_ractor
|
|
51
|
+
assert_equal MyGem.render("<p>hi</p>"), Ractor.new { MyGem.render("<p>hi</p>") }.value
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Every constant under MyGem is shareable, so the next mutable one fails here.
|
|
55
|
+
def test_constants_are_shareable
|
|
56
|
+
unshareable = constants_under(MyGem).reject { |name, value| MAIN_ONLY.key?(name) || Ractor.shareable?(value) }
|
|
57
|
+
assert_empty unshareable.map { |name, value| "#{name} (#{value.class})" }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def constants_under(mod)
|
|
63
|
+
mod.constants(false).reject { mod.autoload?(_1) }.flat_map do |name|
|
|
64
|
+
value = mod.const_get(name, false)
|
|
65
|
+
own = value.is_a?(Module) && value.name == "#{mod.name}::#{name}"
|
|
66
|
+
[["#{mod.name}::#{name}", value], *(own ? constants_under(value) : [])]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# RSpec
|
|
74
|
+
RSpec.describe "MyGem in a Ractor" do
|
|
75
|
+
main_only = { "MyGem::REGISTRY" => "apps add to it at boot" }.freeze
|
|
76
|
+
|
|
77
|
+
before { skip "needs Ruby 4.0 (Ractor#value)" unless defined?(Ractor) && Ractor.method_defined?(:value) }
|
|
78
|
+
|
|
79
|
+
it "runs the entry point inside a non-main Ractor" do
|
|
80
|
+
expect(Ractor.new { MyGem.render("<p>hi</p>") }.value).to eq(MyGem.render("<p>hi</p>"))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "keeps every constant under MyGem shareable" do
|
|
84
|
+
walk = lambda do |mod|
|
|
85
|
+
mod.constants(false).reject { mod.autoload?(_1) }.flat_map do |name|
|
|
86
|
+
value = mod.const_get(name, false)
|
|
87
|
+
own = value.is_a?(Module) && value.name == "#{mod.name}::#{name}"
|
|
88
|
+
[["#{mod.name}::#{name}", value], *(own ? walk.call(value) : [])]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
unshareable = walk.call(MyGem).reject { |name, value| main_only.key?(name) || Ractor.shareable?(value) }
|
|
92
|
+
expect(unshareable.map { |name, value| "#{name} (#{value.class})" }).to be_empty
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A failure names the constant and its class, e.g. `Expected ["MyGem::Html::ESCAPES (Hash)"] to be empty.` The Ractor block must not use outer local variables: write its input inline, or reach it through a constant.
|
|
98
|
+
|
|
99
|
+
What these two leave out, and the kit covers:
|
|
100
|
+
|
|
101
|
+
- class and module instance variables (a `@cache ||= {}` memo);
|
|
102
|
+
- which value inside a constant is the problem: they name `MyGem::SETTINGS (Hash)`, the audit names `[:hooks] is a Proc that is never shareable`;
|
|
103
|
+
- unloaded autoloads, which they skip without a word (the audit lists them);
|
|
104
|
+
- races between threads and fibers.
|
|
105
|
+
|
|
106
|
+
Neither sees a constant the gem defines under another namespace, such as an Active Job adapter in `ActiveJob::QueueAdapters`: walk that namespace too, or pass it to `--namespace`.
|
|
107
|
+
|
|
108
|
+
## Adding it to a gem
|
|
109
|
+
|
|
14
110
|
**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
111
|
|
|
16
112
|
```ruby
|
|
@@ -105,7 +201,7 @@ bundle exec conformance-audit --strict #
|
|
|
105
201
|
bundle exec conformance-audit --require ./lib/my_gem --namespace MyGem --strict # flags override them
|
|
106
202
|
```
|
|
107
203
|
|
|
108
|
-
Requires the files, walks every loaded module under the namespaces (`--require` and `--namespace` repeat; without them it reads `audit.require` and `audit.namespace`), 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:
|
|
204
|
+
Requires the files (an entry starting with `./`, `../`, `~` or `/` is a path; any other is required by name from the load path, as a gem, unless it names an existing file), walks every loaded module under the namespaces (`--require` and `--namespace` repeat; without them it reads `audit.require` and `audit.namespace`), 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:
|
|
109
205
|
|
|
110
206
|
```
|
|
111
207
|
conformance-audit: 2 findings in MyGem (1 allowlisted main-only; 14 modules audited, 0 unloaded autoloads skipped)
|
|
@@ -147,6 +243,11 @@ probes:
|
|
|
147
243
|
gem_name: my_gem # required for the probes job
|
|
148
244
|
paths: [lib] # default [lib]
|
|
149
245
|
roundhouse_app: spec/dummy # optional
|
|
246
|
+
ractorize: # optional: the report-only ractorize job (below)
|
|
247
|
+
app: spec/dummy # the Rails app to boot, relative to this file
|
|
248
|
+
requests: ["/", "/widgets/1"] # GET paths served inside a non-main Ractor
|
|
249
|
+
audit_namespaces: [MyGem] # default audit.namespace
|
|
250
|
+
setup: bin/rails db:prepare # optional, run in the app
|
|
150
251
|
main_only:
|
|
151
252
|
- name: MyGem::Registry.@lock # class/module ivar: Owner.@ivar
|
|
152
253
|
reason: guards boot-time registration; no non-main Ractor ever registers
|
|
@@ -204,11 +305,12 @@ jobs:
|
|
|
204
305
|
|
|
205
306
|
To make it a merge requirement, add `conformance / Conformance settings` and `conformance / Conformance (Ruby 4.0)` to the required status checks in the repo's branch ruleset. Require both: if the settings job fails, the gate is skipped, and a skipped check does not block a merge. The prefix is the caller's job id (`conformance` above) and the Ruby is `ractor.ruby`, so the names change with either. Leave the probes out: they never fail. A repo or org that only allows selected actions must allow `zoolutions/conformance-kit/.github/workflows/conformance.yml@*`.
|
|
206
307
|
|
|
207
|
-
The workflow has
|
|
308
|
+
The workflow has four jobs:
|
|
208
309
|
|
|
209
|
-
- **Conformance settings** reads `ractor.ruby` and `
|
|
310
|
+
- **Conformance settings** reads `ractor.ruby`, `ractor.working_directory` and `ractorize.app`.
|
|
210
311
|
- **Conformance (Ruby `ractor.ruby`)**, the gate: the version handshake, then `bundle exec conformance-kit check`. A `postgres:18` service (user and password `postgres`, port 5432) is always up; the workflow exports nothing, so `ractor.env` and `ractor.setup` wire it.
|
|
211
312
|
- **Compiler probes (report-only)**: `rubocop_spinel` (gem-installed in the probe, never in your Gemfile), `spinel-compat probe <probes.gem_name> --dir .` and, when `probes.roundhouse_app` is set, `roundhouse check` (the release tarball, checked against a pinned SHA-256). Every probe continues on error; the reports go to the run summary and a `conformance-probes` artifact. It never fails a build, and it skips with a notice when `probes.gem_name` is unset.
|
|
313
|
+
- **Ractorize (report-only)**, only when `ractorize.app` is set: [the ractorize job](#the-ractorize-job-report-only) on Rails main. It never fails the workflow; leave it out of the required checks.
|
|
212
314
|
|
|
213
315
|
### Staying current
|
|
214
316
|
|
|
@@ -240,6 +342,7 @@ updates:
|
|
|
240
342
|
|
|
241
343
|
```bash
|
|
242
344
|
bundle exec conformance-kit check # doctor, ractor.setup, ractor.test_command, then conformance-audit --strict
|
|
345
|
+
bundle exec conformance-kit check --ractorize # the ractorize job (below), with BUNDLE_GEMFILE=Gemfile.ractorize
|
|
243
346
|
bundle exec conformance-kit doctor # the static checks only
|
|
244
347
|
bundle exec conformance-kit config probes.gem_name
|
|
245
348
|
```
|
|
@@ -256,6 +359,63 @@ bundle exec conformance-kit config probes.gem_name
|
|
|
256
359
|
| 1 | a check failed (`check` exits with the failing stage's status) |
|
|
257
360
|
| 2 | usage error or an invalid `.conformance.yml` |
|
|
258
361
|
|
|
362
|
+
### The ractorize job (report-only)
|
|
363
|
+
|
|
364
|
+
Rails 8.2 can serve an app's requests from several Ractors: `Rails.application.ractorize!` makes the booted app shareable, and every gem on the request path has to cope. The Ruby 4.0 canary runs your gem's own code in a Ractor. This job runs it inside a real app that has been ractorized, where Rails' framework hooks, the app's boot order and other engines meet it.
|
|
365
|
+
|
|
366
|
+
```yaml
|
|
367
|
+
# .conformance.yml
|
|
368
|
+
ractorize:
|
|
369
|
+
app: spec/dummy # the Rails app to boot (its config/environment.rb), relative to this file
|
|
370
|
+
requests: ["/", "/widgets/1"] # GET paths, each served inside a non-main Ractor after ractorize!
|
|
371
|
+
audit_namespaces: [MyGem] # audited after ractorize!; default audit.namespace
|
|
372
|
+
setup: bin/rails db:prepare db:schema:cache:dump assets:precompile # optional
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
`conformance-kit check --ractorize` runs it locally, and the workflow's Ractorize job runs the same when `ractorize.app` is set:
|
|
376
|
+
|
|
377
|
+
1. `ractorize.setup` runs in the app, with `ractor.env`, `RAILS_ENV=production` and `SECRET_KEY_BASE_DUMMY=1`.
|
|
378
|
+
2. A new process sets `ActiveSupport::Ractors.unshareable_proc_action = :raise` and boots the app in production. The test environment can't serve a ractorized request yet ([rails/rails#59168](https://github.com/rails/rails/issues/59168)).
|
|
379
|
+
3. It calls `Rails.application.ractorize!`, then `require "i18n/ractorize"`.
|
|
380
|
+
4. Each request runs inside `Ractor.new`. An exception fails it, and so does a 5xx; either way the report names the root cause and its first frame, including an exception the app's error page swallowed.
|
|
381
|
+
5. The audit runs on the namespaces in the same process, with `main_only:` applied, so it sees what a request Ractor reads.
|
|
382
|
+
|
|
383
|
+
```
|
|
384
|
+
ractorize: boot: ok
|
|
385
|
+
ractorize: Rails.application.ractorize!: ok
|
|
386
|
+
GET /widget -> 500 (0 bytes): Ractor::IsolationError: can not get unshareable values from instance variables of classes/modules from non-main Ractors (@registry from MyGem::Widget)
|
|
387
|
+
at /home/runner/work/my_gem/my_gem/spec/dummy/app/controllers/widgets_controller.rb:5:in 'WidgetsController#show'
|
|
388
|
+
ractorize: audit MyGem after ractorize!: 1 findings
|
|
389
|
+
MyGem::Widget.@registry (class ivar) Hash: [:names] is an Array that is not frozen: freeze it at boot (Ractor.make_shareable)
|
|
390
|
+
ractorize failed: 2 failures (failed requests and audit findings)
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
A block Rails can't make shareable stops the boot, and Ruby's message names the block's file and line (`Proc's self is not shareable: #<Proc:... lib/my_gem/engine.rb:8>`). Below Ruby 4.0, or on a Rails without `ractorize!`, the job skips with the reason and exits 0.
|
|
394
|
+
|
|
395
|
+
**What the app needs.** Production settings that can be made shareable, as in Rails' own `railties/test/application/ractors_test.rb`:
|
|
396
|
+
|
|
397
|
+
- `config.enable_reloading = false` and `config.eager_load = true` (a generated `production.rb` sets both);
|
|
398
|
+
- `config.logger = ActiveSupport::Ractors::Logger.new`;
|
|
399
|
+
- `config.cache_store = :null_store`, `config.public_file_server.enabled = false`, `config.action_cable.mount_path = nil` and `config.active_job.queue_adapter = :inline`.
|
|
400
|
+
|
|
401
|
+
With Active Record, it also needs a dumped schema cache (`db:schema:cache:dump`) and `config.active_record.check_schema_cache_dump_version = false`: without them, a model's first query inside a Ractor fails, and nothing warns. Precompile assets in `setup`. Framework state on your engine's classes is main-only, so list it once in `main_only:` (e.g. `ancestor: Rails::Engine` with `ivars: ["@called_from", "@initializers"]`).
|
|
402
|
+
|
|
403
|
+
**The bundle.** Until they ship, `ractorize!` needs:
|
|
404
|
+
|
|
405
|
+
- Rails main;
|
|
406
|
+
- Rack main, for `rack/ractorize`;
|
|
407
|
+
- Shopify's i18n branch, pinned to [Shopify/i18n#5](https://github.com/Shopify/i18n/pull/5) until that merges: without it, every render fails on `I18n.fallbacks`.
|
|
408
|
+
|
|
409
|
+
`config/ractorize.gemfile` swaps them into your Gemfile. It drops your own lines for those gems and Rails' component gems (Bundler refuses a gem declared twice) and keeps everything else. Copy it next to your Gemfile and bundle with it, as the workflow does; keep `Gemfile.ractorize` and its lock out of git.
|
|
410
|
+
|
|
411
|
+
```bash
|
|
412
|
+
cp "$(bundle exec ruby -e 'print Gem.loaded_specs["conformance-kit"].full_gem_path')/config/ractorize.gemfile" Gemfile.ractorize
|
|
413
|
+
BUNDLE_GEMFILE=Gemfile.ractorize bundle install
|
|
414
|
+
BUNDLE_GEMFILE=Gemfile.ractorize bundle exec conformance-kit check --ractorize
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
**Report-only.** Rails main moves under it, so the job is `continue-on-error` and never fails the workflow until Rails 8.2 ships `ractorize!`. This repo proves it the same way, with a "Ractorize lane" in its own CI: a failing and a passing fixture app on that bundle.
|
|
418
|
+
|
|
259
419
|
### The version handshake
|
|
260
420
|
|
|
261
421
|
The behaviour a repo is held to comes from the `conformance-kit` in its lockfile; only the plumbing comes from the workflow tag. So that a stale lockfile cannot pass a newer workflow vacuously, the workflow reads its own tag from `job.workflow_ref` (the called workflow's ref; `github.workflow_ref` is the caller's) and fails with "bump conformance-kit to ~> X.Y" when the repo resolves anything older than `X.Y`. The version is derived, never stored. When the caller pins anything other than a `vX.Y.Z` tag (a branch, a commit SHA, or this repo's own `./` caller) the handshake is skipped with a notice, and so it is on GitHub Enterprise Server, which has no `job.workflow_ref`.
|
|
@@ -264,6 +424,11 @@ The behaviour a repo is held to comes from the `conformance-kit` in its lockfile
|
|
|
264
424
|
|
|
265
425
|
```bash
|
|
266
426
|
bundle exec rake # rspec + rubocop; CI runs Ruby 3.2, 3.4 and 4.0
|
|
427
|
+
|
|
428
|
+
# The ractorize lane (Ruby 4.0; CI's report-only "Ractorize lane" job)
|
|
429
|
+
cp config/ractorize.gemfile Gemfile.ractorize
|
|
430
|
+
BUNDLE_GEMFILE=Gemfile.ractorize bundle install
|
|
431
|
+
BUNDLE_GEMFILE=Gemfile.ractorize bundle exec rspec --tag ractorize
|
|
267
432
|
```
|
|
268
433
|
|
|
269
434
|
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`, strict, nothing skipped), and `conformance.yml` runs on its own pull requests.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The ractorize job's bundle: the repo's own Gemfile, with Rails, Rack and i18n
|
|
4
|
+
# swapped for the branches `Rails.application.ractorize!` needs until they ship
|
|
5
|
+
# (Rails 8.2, Rack's rack/ractorize, ruby-i18n/i18n#741). Copy it next to the
|
|
6
|
+
# repo's Gemfile as Gemfile.ractorize and run with BUNDLE_GEMFILE=Gemfile.ractorize.
|
|
7
|
+
ractorize_sources = {
|
|
8
|
+
"rails" => { github: "rails/rails", branch: "main" },
|
|
9
|
+
"rack" => { github: "rack/rack", branch: "main" },
|
|
10
|
+
"i18n" => { github: "Shopify/i18n", ref: "refs/pull/5/head" }
|
|
11
|
+
}.freeze
|
|
12
|
+
# Rails' own gems come from the same checkout as rails.
|
|
13
|
+
replaced = %w[railties actionpack actionview activesupport activemodel activerecord activejob actionmailer
|
|
14
|
+
actioncable activestorage actiontext actionmailbox] + ractorize_sources.keys
|
|
15
|
+
|
|
16
|
+
# The repo's own lines for these gems are dropped: Bundler refuses a gem declared twice.
|
|
17
|
+
reading_repo_gemfile = true
|
|
18
|
+
define_singleton_method(:gem) do |name, *args, **options, &block|
|
|
19
|
+
super(name, *args, **options, &block) unless reading_repo_gemfile && replaced.include?(name)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
eval_gemfile File.expand_path("Gemfile", __dir__)
|
|
23
|
+
reading_repo_gemfile = false
|
|
24
|
+
|
|
25
|
+
ractorize_sources.each { |name, source| gem name, **source }
|
|
@@ -56,7 +56,7 @@ module ConformanceKit
|
|
|
56
56
|
def parser(options)
|
|
57
57
|
OptionParser.new do |o|
|
|
58
58
|
o.banner = "Usage: conformance-audit [--require FILE] [--namespace CONST] [--strict] [--config PATH]"
|
|
59
|
-
o.on("--require FILE", "Require FILE
|
|
59
|
+
o.on("--require FILE", "Require FILE or a gem by name first (repeatable; default audit.require)") do |file|
|
|
60
60
|
options[:requires] << file
|
|
61
61
|
end
|
|
62
62
|
o.on("--namespace CONST", "Audit CONST and all under it (repeatable; default audit.namespace)") do |name|
|
|
@@ -78,7 +78,7 @@ module ConformanceKit
|
|
|
78
78
|
def requires(options, config)
|
|
79
79
|
return config.audit_requires if options[:requires].empty?
|
|
80
80
|
|
|
81
|
-
options[:requires].map {
|
|
81
|
+
options[:requires].map { Config.require_target(_1, Dir.pwd) }
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
def check_namespace(name)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
module ConformanceKit
|
|
7
|
+
class CLI
|
|
8
|
+
# `conformance-kit check --ractorize`: what the workflow's ractorize job
|
|
9
|
+
# runs. ractorize.setup in the app (RAILS_ENV=production), then the runner
|
|
10
|
+
# in its own process, both with ractor.env. Stops at the first failing stage.
|
|
11
|
+
class RactorizeCheck
|
|
12
|
+
BOOT = File.expand_path("../ractorize/boot.rb", __dir__).freeze
|
|
13
|
+
PRODUCTION = { "RAILS_ENV" => "production", "SECRET_KEY_BASE_DUMMY" => "1" }.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(config, out:)
|
|
16
|
+
@config = config
|
|
17
|
+
@out = out
|
|
18
|
+
@app = config.ractorize_app
|
|
19
|
+
raise ConfigError, "#{config.source}: ractorize.app is not set (the Rails app to boot)" unless @app
|
|
20
|
+
|
|
21
|
+
environment = File.join(@app, "config/environment.rb")
|
|
22
|
+
return if File.file?(environment)
|
|
23
|
+
|
|
24
|
+
raise ConfigError, "#{config.source}: ractorize.app: #{environment} does not exist"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run
|
|
28
|
+
setup = @config["ractorize.setup"]
|
|
29
|
+
code = setup ? shell(setup) : 0
|
|
30
|
+
return failed("ractorize.setup", code) unless code.zero?
|
|
31
|
+
|
|
32
|
+
code = runner
|
|
33
|
+
code.zero? ? code : failed("ractorize", code)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def env = @config["ractor.env"].merge(PRODUCTION)
|
|
39
|
+
|
|
40
|
+
def shell(command)
|
|
41
|
+
@out.puts "==> #{command}"
|
|
42
|
+
@out.flush
|
|
43
|
+
# system returns nil when the command could not be spawned at all.
|
|
44
|
+
return 127 if system(env, command, chdir: @app).nil?
|
|
45
|
+
|
|
46
|
+
Process.last_status.exitstatus || 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def runner
|
|
50
|
+
@out.puts "==> ractorize #{@app}"
|
|
51
|
+
@out.flush
|
|
52
|
+
output, status = Open3.capture2e(env, RbConfig.ruby, BOOT, File.expand_path(@config.source), chdir: @app)
|
|
53
|
+
@out.print output
|
|
54
|
+
status.exitstatus || 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def failed(stage, code)
|
|
58
|
+
@out.puts "conformance-kit check failed at #{stage} (exit #{code})"
|
|
59
|
+
code
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/conformance_kit/cli.rb
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
-
|
|
4
|
+
require_relative "../conformance_kit"
|
|
5
5
|
require_relative "config"
|
|
6
6
|
require_relative "cli/doctor"
|
|
7
7
|
require_relative "cli/check"
|
|
8
|
+
require_relative "cli/ractorize_check"
|
|
8
9
|
|
|
9
10
|
module ConformanceKit
|
|
10
|
-
# `conformance-kit check|doctor|config KEY [--config PATH] [--expect X.Y]`
|
|
11
|
+
# `conformance-kit check|doctor|config KEY [--config PATH] [--expect X.Y] [--ractorize]`
|
|
11
12
|
#
|
|
12
13
|
# Exit codes: 0 ok, 1 a check failed (`check` passes on its failing stage's
|
|
13
14
|
# status), 2 usage or config error.
|
|
@@ -15,6 +16,8 @@ module ConformanceKit
|
|
|
15
16
|
USAGE = <<~TEXT
|
|
16
17
|
Usage: conformance-kit COMMAND [--config PATH]
|
|
17
18
|
check [--expect X.Y] doctor, ractor.setup, ractor.test_command, then conformance-audit --strict
|
|
19
|
+
check --ractorize ractorize.setup, then boot ractorize.app with Rails.application.ractorize!,
|
|
20
|
+
serve ractorize.requests inside non-main Ractors and audit (report-only job)
|
|
18
21
|
doctor [--expect X.Y] .conformance.yml, .rubocop.yml and the bundle are set up for the conformance workflow
|
|
19
22
|
config KEY print a .conformance.yml value (dotted key, e.g. probes.gem_name)
|
|
20
23
|
TEXT
|
|
@@ -47,16 +50,17 @@ module ConformanceKit
|
|
|
47
50
|
case command
|
|
48
51
|
when "config" then print_value(options)
|
|
49
52
|
when "doctor" then doctor(options).run
|
|
50
|
-
when "check" then
|
|
53
|
+
when "check" then check(options)
|
|
51
54
|
else raise ArgumentError, command ? "unknown command #{command}" : "no command"
|
|
52
55
|
end
|
|
53
56
|
end
|
|
54
57
|
|
|
55
58
|
def parse(argv)
|
|
56
|
-
options = { config: Config::DEFAULT_PATH, expect: nil }
|
|
59
|
+
options = { config: Config::DEFAULT_PATH, expect: nil, ractorize: false }
|
|
57
60
|
options[:args] = OptionParser.new do |o|
|
|
58
61
|
o.on("--config PATH") { options[:config] = _1 }
|
|
59
62
|
o.on("--expect X.Y") { options[:expect] = expected_version(_1) }
|
|
63
|
+
o.on("--ractorize") { options[:ractorize] = true }
|
|
60
64
|
end.parse(argv)
|
|
61
65
|
options
|
|
62
66
|
end
|
|
@@ -69,6 +73,12 @@ module ConformanceKit
|
|
|
69
73
|
|
|
70
74
|
def config(options) = Config.load(options[:config])
|
|
71
75
|
|
|
76
|
+
def check(options)
|
|
77
|
+
return RactorizeCheck.new(config(options), out: @out).run if options[:ractorize]
|
|
78
|
+
|
|
79
|
+
Check.new(config(options), doctor(options), out: @out).run
|
|
80
|
+
end
|
|
81
|
+
|
|
72
82
|
def doctor(options)
|
|
73
83
|
Doctor.new(config(options), out: @out, version: @version, specs: @specs, expect: options[:expect])
|
|
74
84
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "rbconfig"
|
|
3
4
|
require "yaml"
|
|
4
5
|
require_relative "audit/allowlist"
|
|
5
6
|
|
|
@@ -13,6 +14,7 @@ module ConformanceKit
|
|
|
13
14
|
# audit: { require: [], namespace: [], strict_autoloads: false, skip_autoloads: [] }
|
|
14
15
|
# ractor: { ruby: "4.0", test_command: "...", env: {}, setup: nil, working_directory: "." }
|
|
15
16
|
# probes: { gem_name: nil, paths: [lib], roundhouse_app: nil }
|
|
17
|
+
# ractorize: { app: nil, requests: [], audit_namespaces: [audit.namespace], setup: nil }
|
|
16
18
|
# main_only: []
|
|
17
19
|
class Config
|
|
18
20
|
DEFAULT_PATH = ".conformance.yml"
|
|
@@ -30,6 +32,10 @@ module ConformanceKit
|
|
|
30
32
|
"probes.gem_name" => :string,
|
|
31
33
|
"probes.paths" => :list,
|
|
32
34
|
"probes.roundhouse_app" => :string,
|
|
35
|
+
"ractorize.app" => :string,
|
|
36
|
+
"ractorize.requests" => :list,
|
|
37
|
+
"ractorize.audit_namespaces" => :list,
|
|
38
|
+
"ractorize.setup" => :string,
|
|
33
39
|
"main_only" => :entries
|
|
34
40
|
}.freeze
|
|
35
41
|
|
|
@@ -43,10 +49,11 @@ module ConformanceKit
|
|
|
43
49
|
"ractor.env" => {}.freeze,
|
|
44
50
|
"ractor.working_directory" => ".",
|
|
45
51
|
"probes.paths" => ["lib"].freeze,
|
|
52
|
+
"ractorize.requests" => [].freeze,
|
|
46
53
|
"main_only" => [].freeze
|
|
47
54
|
}.freeze
|
|
48
55
|
|
|
49
|
-
SECTIONS = %w[audit ractor probes].freeze
|
|
56
|
+
SECTIONS = %w[audit ractor probes ractorize].freeze
|
|
50
57
|
|
|
51
58
|
def self.load(path = DEFAULT_PATH)
|
|
52
59
|
root = File.dirname(File.expand_path(path))
|
|
@@ -64,7 +71,9 @@ module ConformanceKit
|
|
|
64
71
|
@source = source
|
|
65
72
|
@root = root
|
|
66
73
|
@exists = exists
|
|
67
|
-
|
|
74
|
+
unless data.is_a?(Hash)
|
|
75
|
+
raise ConfigError, "#{source}: expected a mapping (audit:, ractor:, probes:, ractorize:, main_only:)"
|
|
76
|
+
end
|
|
68
77
|
|
|
69
78
|
pairs = flatten(data).each { |key, _| unknown_key!(key, TYPES.keys) unless TYPES.key?(key) }
|
|
70
79
|
# A key left blank is unset: its default applies, as in the workflow's yq read.
|
|
@@ -82,10 +91,34 @@ module ConformanceKit
|
|
|
82
91
|
@values.fetch(key) { DEFAULTS[key] }
|
|
83
92
|
end
|
|
84
93
|
|
|
85
|
-
|
|
94
|
+
# An `audit.require` or `--require` entry: a path relative to +base+ when
|
|
95
|
+
# written as one (`./lib/my_gem`, `/abs/path`) or when it names a Ruby file
|
|
96
|
+
# or native extension there, otherwise a feature for the load path, such as
|
|
97
|
+
# an installed gem.
|
|
98
|
+
def self.require_target(entry, base)
|
|
99
|
+
path = File.expand_path(entry, base)
|
|
100
|
+
explicit = entry.start_with?("./", "../", "~") || File.absolute_path?(entry)
|
|
101
|
+
explicit || requirable_file?(path) ? path : entry
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.requirable_file?(path)
|
|
105
|
+
["", ".rb", ".#{RbConfig::CONFIG['DLEXT']}"].any? { File.file?("#{path}#{_1}") }
|
|
106
|
+
end
|
|
107
|
+
private_class_method :requirable_file?
|
|
108
|
+
|
|
109
|
+
def audit_requires = self["audit.require"].map { self.class.require_target(_1, root) }
|
|
86
110
|
def strict_autoloads? = self["audit.strict_autoloads"]
|
|
87
111
|
def working_directory = File.expand_path(self["ractor.working_directory"], root)
|
|
88
112
|
|
|
113
|
+
# The Rails app the ractorize job boots, or nil when `ractorize.app` is unset.
|
|
114
|
+
def ractorize_app = self["ractorize.app"]&.then { File.expand_path(_1, root) }
|
|
115
|
+
|
|
116
|
+
# What the ractorize job audits after `ractorize!`: its own list, or audit.namespace.
|
|
117
|
+
def ractorize_namespaces
|
|
118
|
+
namespaces = self["ractorize.audit_namespaces"]
|
|
119
|
+
namespaces.nil? || namespaces.empty? ? self["audit.namespace"] : namespaces
|
|
120
|
+
end
|
|
121
|
+
|
|
89
122
|
private
|
|
90
123
|
|
|
91
124
|
def flatten(data)
|
|
@@ -100,9 +133,12 @@ module ConformanceKit
|
|
|
100
133
|
end
|
|
101
134
|
end
|
|
102
135
|
|
|
136
|
+
NAMESPACE_KEYS = %w[audit.namespace ractorize.audit_namespaces].freeze
|
|
137
|
+
private_constant :NAMESPACE_KEYS
|
|
138
|
+
|
|
103
139
|
# A namespace is matched by name: `::MyGem` is `MyGem`, as with --namespace.
|
|
104
140
|
def normalize(key, value)
|
|
105
|
-
key
|
|
141
|
+
NAMESPACE_KEYS.include?(key) ? value.map { _1.delete_prefix("::") } : value
|
|
106
142
|
end
|
|
107
143
|
|
|
108
144
|
def validate(key, value)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The ractorize job's process: `ruby boot.rb .conformance.yml`, under the
|
|
4
|
+
# bundle that has Rails (BUNDLE_GEMFILE=Gemfile.ractorize). conformance-kit
|
|
5
|
+
# check --ractorize starts it; it never runs inside the kit's own process.
|
|
6
|
+
require_relative "runner"
|
|
7
|
+
|
|
8
|
+
$stdout.sync = true
|
|
9
|
+
exit ConformanceKit::Ractorize::Runner.new(ConformanceKit::Config.load(ARGV.fetch(0)), out: $stdout).run
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module ConformanceKit
|
|
6
|
+
module Ractorize
|
|
7
|
+
# One request the ractorize job serves. Its env is built on the main Ractor
|
|
8
|
+
# from Strings only, so it crosses into the request Ractor as a copy; the
|
|
9
|
+
# IO objects Rack wants are made inside that Ractor.
|
|
10
|
+
module Request
|
|
11
|
+
SERVER = {
|
|
12
|
+
"SCRIPT_NAME" => "",
|
|
13
|
+
"SERVER_NAME" => "localhost",
|
|
14
|
+
"SERVER_PORT" => "80",
|
|
15
|
+
"SERVER_PROTOCOL" => "HTTP/1.1",
|
|
16
|
+
"HTTP_HOST" => "localhost",
|
|
17
|
+
"HTTP_ACCEPT" => "text/html",
|
|
18
|
+
"rack.url_scheme" => "http"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def self.env_for(path)
|
|
22
|
+
path_info, query = path.split("?", 2)
|
|
23
|
+
{ "REQUEST_METHOD" => "GET", "PATH_INFO" => path_info, "QUERY_STRING" => query.to_s }.merge(SERVER)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# `{ status:, body:, error: }`, where error is describe_error's triple:
|
|
27
|
+
# the exception the app raised, or the one its error page swallowed.
|
|
28
|
+
def self.serve(app, path)
|
|
29
|
+
Ractor.new(app, env_for(path)) { |rack_app, env| Request.call(rack_app, env) }.value
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Runs inside the request Ractor.
|
|
33
|
+
def self.call(app, env)
|
|
34
|
+
env = env.merge("rack.input" => StringIO.new(+""), "rack.errors" => StringIO.new)
|
|
35
|
+
status, _headers, body = app.call(env)
|
|
36
|
+
text = read(body)
|
|
37
|
+
swallowed = env["action_dispatch.exception"]
|
|
38
|
+
{ status: status, body: text, error: swallowed && describe_error(swallowed) }
|
|
39
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- every failure is the report
|
|
40
|
+
{ status: nil, body: nil, error: describe_error(e) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.read(body)
|
|
44
|
+
text = +""
|
|
45
|
+
body.each { text << _1 }
|
|
46
|
+
text
|
|
47
|
+
ensure
|
|
48
|
+
body.close if body.respond_to?(:close)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The root cause's class, message and first frame, unfiltered (no
|
|
52
|
+
# backtrace cleaner), so the frame names the file and line to fix.
|
|
53
|
+
def self.describe_error(error)
|
|
54
|
+
root = error
|
|
55
|
+
root = root.cause while root.cause
|
|
56
|
+
[root.class.name, root.message, root.backtrace&.first]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ractorize"
|
|
4
|
+
require_relative "../audit"
|
|
5
|
+
require_relative "request"
|
|
6
|
+
|
|
7
|
+
module ConformanceKit
|
|
8
|
+
module Ractorize
|
|
9
|
+
# Runs in its own process (boot.rb): it boots the app, freezes it for good
|
|
10
|
+
# and leaves global state behind. Boots in production with
|
|
11
|
+
# `unshareable_proc_action = :raise`, calls `Rails.application.ractorize!`,
|
|
12
|
+
# serves each `ractorize.requests` path inside a non-main Ractor, then audits
|
|
13
|
+
# `ractorize.audit_namespaces` in the same process. Exits 0 when all of it
|
|
14
|
+
# passes or the run is skipped (with the reason), 1 otherwise.
|
|
15
|
+
class Runner
|
|
16
|
+
def initialize(config, out:)
|
|
17
|
+
@config = config
|
|
18
|
+
@out = out
|
|
19
|
+
@failures = 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run
|
|
23
|
+
reason = unavailable_reason
|
|
24
|
+
return skip(reason) if reason
|
|
25
|
+
|
|
26
|
+
ActiveSupport::Ractors.unshareable_proc_action = :raise
|
|
27
|
+
return 1 unless stage("boot") { boot }
|
|
28
|
+
return 1 unless stage("Rails.application.ractorize!") { ractorize }
|
|
29
|
+
|
|
30
|
+
serve_requests
|
|
31
|
+
audit
|
|
32
|
+
summary
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def unavailable_reason
|
|
38
|
+
return rails_reason if Ractorize.available?
|
|
39
|
+
|
|
40
|
+
"Ractor#value is unavailable on #{RUBY_ENGINE} #{RUBY_VERSION} (needs Ruby 4.0)"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Checked before :raise is armed, so a Rails without ractorize! skips instead of failing its boot.
|
|
44
|
+
def rails_reason
|
|
45
|
+
require "rails"
|
|
46
|
+
require "active_support/ractors"
|
|
47
|
+
return if Rails::Application.method_defined?(:ractorize!)
|
|
48
|
+
|
|
49
|
+
"Rails #{Rails.version} has no Rails.application.ractorize!: run it with BUNDLE_GEMFILE=Gemfile.ractorize"
|
|
50
|
+
rescue LoadError => e
|
|
51
|
+
return "Rails is not in the bundle: run it with BUNDLE_GEMFILE=Gemfile.ractorize" if e.path == "rails"
|
|
52
|
+
|
|
53
|
+
"ActiveSupport::Ractors is unavailable (Rails before 8.2): run it with BUNDLE_GEMFILE=Gemfile.ractorize"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def boot
|
|
57
|
+
ENV["RAILS_ENV"] = "production"
|
|
58
|
+
ENV["SECRET_KEY_BASE_DUMMY"] ||= "1"
|
|
59
|
+
environment = File.join(@config.ractorize_app, "config/environment.rb")
|
|
60
|
+
require environment
|
|
61
|
+
raise "#{environment} defined no Rails.application" unless defined?(::Rails) && ::Rails.application
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def ractorize
|
|
65
|
+
Warning[:experimental] = false
|
|
66
|
+
Rails.application.ractorize!
|
|
67
|
+
require "i18n/ractorize"
|
|
68
|
+
rescue LoadError => e
|
|
69
|
+
raise unless e.path == "i18n/ractorize"
|
|
70
|
+
|
|
71
|
+
@out.puts "ractorize: note: i18n/ractorize is unavailable (released i18n): I18n reads fail in a Ractor"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def stage(name)
|
|
75
|
+
yield
|
|
76
|
+
@out.puts "ractorize: #{name}: ok"
|
|
77
|
+
true
|
|
78
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- the failure is the report
|
|
79
|
+
@out.puts "ractorize: #{name} failed: #{format(Request.describe_error(e))}"
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def serve_requests
|
|
84
|
+
@config["ractorize.requests"].each do |path|
|
|
85
|
+
result = Request.serve(Rails.application, path)
|
|
86
|
+
failed = result[:error] || result[:status].nil? || result[:status] >= 500
|
|
87
|
+
@failures += 1 if failed
|
|
88
|
+
@out.puts " GET #{path} -> #{describe(result)}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def describe(result)
|
|
93
|
+
status = result[:status] ? "#{result[:status]} (#{result[:body].bytesize} bytes)" : "raised"
|
|
94
|
+
result[:error] ? "#{status}: #{format(result[:error])}" : status
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def format((klass, message, frame)) = "#{klass}: #{message}\n at #{frame}"
|
|
98
|
+
|
|
99
|
+
def audit
|
|
100
|
+
namespaces = @config.ractorize_namespaces
|
|
101
|
+
return @out.puts("ractorize: audit: nothing to audit (set ractorize.audit_namespaces)") if namespaces.empty?
|
|
102
|
+
|
|
103
|
+
missing = namespaces.reject { Object.const_defined?(_1) }
|
|
104
|
+
return audit_failed("namespace #{missing.join(', ')} is not defined after boot") if missing.any?
|
|
105
|
+
|
|
106
|
+
findings = Audit.new(namespaces: namespaces, allowlist: @config.allowlist,
|
|
107
|
+
skip_autoloads: @config.skip_autoloads).findings
|
|
108
|
+
@failures += findings.size
|
|
109
|
+
@out.puts "ractorize: audit #{namespaces.join(', ')} after ractorize!: #{findings.size} findings"
|
|
110
|
+
findings.each { @out.puts " #{_1}" }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def audit_failed(message)
|
|
114
|
+
@failures += 1
|
|
115
|
+
@out.puts "ractorize: audit failed: #{message}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def summary
|
|
119
|
+
return 0.tap { @out.puts "ractorize passed" } if @failures.zero?
|
|
120
|
+
|
|
121
|
+
@out.puts "ractorize failed: #{@failures} failures (failed requests and audit findings)"
|
|
122
|
+
1
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def skip(reason)
|
|
126
|
+
@out.puts "ractorize: skipped: #{reason}"
|
|
127
|
+
0
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConformanceKit
|
|
4
|
+
# The ractorize job: boot a gem's test app the way Rails 8.2 runs it across
|
|
5
|
+
# Ractors (`Rails.application.ractorize!`), serve requests inside non-main
|
|
6
|
+
# Ractors and audit what they read. See README "The ractorize job".
|
|
7
|
+
module Ractorize
|
|
8
|
+
# Ractor#value is Ruby 4.0's; before it, results came back through #take.
|
|
9
|
+
def self.available? = defined?(::Ractor) && ::Ractor.method_defined?(:value)
|
|
10
|
+
end
|
|
11
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: conformance-kit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- mhenrixon
|
|
@@ -23,6 +23,7 @@ files:
|
|
|
23
23
|
- CHANGELOG.md
|
|
24
24
|
- LICENSE.txt
|
|
25
25
|
- README.md
|
|
26
|
+
- config/ractorize.gemfile
|
|
26
27
|
- config/rubocop.yml
|
|
27
28
|
- config/rubocop_spinel.yml
|
|
28
29
|
- exe/conformance-audit
|
|
@@ -40,8 +41,13 @@ files:
|
|
|
40
41
|
- lib/conformance_kit/cli.rb
|
|
41
42
|
- lib/conformance_kit/cli/check.rb
|
|
42
43
|
- lib/conformance_kit/cli/doctor.rb
|
|
44
|
+
- lib/conformance_kit/cli/ractorize_check.rb
|
|
43
45
|
- lib/conformance_kit/config.rb
|
|
44
46
|
- lib/conformance_kit/minitest.rb
|
|
47
|
+
- lib/conformance_kit/ractorize.rb
|
|
48
|
+
- lib/conformance_kit/ractorize/boot.rb
|
|
49
|
+
- lib/conformance_kit/ractorize/request.rb
|
|
50
|
+
- lib/conformance_kit/ractorize/runner.rb
|
|
45
51
|
- lib/conformance_kit/result.rb
|
|
46
52
|
- lib/conformance_kit/rspec.rb
|
|
47
53
|
- lib/conformance_kit/shareability.rb
|