diff --git a/CHANGELOG.md b/CHANGELOG.md index 4780d9f4c..0cfaeb663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * [#2951](https://github.com/ruby-grape/grape/pull/2951): Re-bench only `master` in the version throughput benchmark and carry released versions' results over - [@ericproulx](https://github.com/ericproulx). * [#2960](https://github.com/ruby-grape/grape/pull/2960): Lint every response of an API once under `lint!`, including the router's 404 and mounted Rack apps, instead of inside each endpoint's stack - [@ericproulx](https://github.com/ericproulx). * [#2966](https://github.com/ruby-grape/grape/pull/2966): Require Ruby 3.3.1, the first 3.3 release that parses anonymous argument forwarding from inside a block - [@ericproulx](https://github.com/ericproulx). +* [#2978](https://github.com/ruby-grape/grape/pull/2978): Add Ractor mode (Ruby 4.0 and later): `Grape.ractor!` and `MyAPI.finalize!` freeze a compiled API, and the process-wide state a request reads, so that it can be served from non-main Ractors - [@ericproulx](https://github.com/ericproulx). * Your contribution here. #### Fixes diff --git a/README.md b/README.md index 062f9ad66..61d7ce437 100644 --- a/README.md +++ b/README.md @@ -4410,6 +4410,45 @@ Grape integrates with following third-party tools: * **[ElasticAPM](https://www.elastic.co/products/apm)** - [elastic-apm](https://github.com/elastic/apm-agent-ruby) gem, [documentation](https://www.elastic.co/guide/en/apm/agent/ruby/3.x/getting-started-rack.html#getting-started-grape) * **[Datadog APM](https://docs.datadoghq.com/tracing/)** - [ddtrace](https://github.com/datadog/dd-trace-rb) gem, [documentation](https://docs.datadoghq.com/tracing/setup_overview/setup/ruby/#grape) +## Ractor Mode + +> Experimental, and only as solid as Ruby's own Ractors, which are experimental themselves. +> Needs Ruby 4.0 or later: earlier Rubies cannot make a `Method` object shareable, and an API is +> full of them -- every endpoint holds its route block as one. `Grape.ractor!` raises there. + +A Grape API is defined, compiled and frozen in the main Ractor, and can then be served from as many Ractors as you like -- in parallel, with no GVL between them. Turn the mode on before the API classes load, and finalize the API once it is fully defined: + +```ruby +# config.ru +require 'grape' +Grape.ractor! + +require_relative 'api' + +run MyAPI.finalize! +``` + +`Grape.ractor!` has to come first because it changes how a route block becomes a method: Ruby refuses to call a method defined from an unshareable `Proc` from another Ractor, so in this mode every block is isolated as it is read. A block that reads an outer variable holding something unshareable cannot be isolated, and says so as the class loads rather than on the first request: + +```ruby +limit = +'10' # a mutable String + +Class.new(Grape::API) do + get('/items') { limit } # Ractor::IsolationError, while the class loads +end +``` + +`finalize!` compiles the API, freezes the whole object graph behind it, and settles the process-wide state a request reads: Grape's own configuration, the lookup tables Rack and Builder fill as they load, and a snapshot of the `grape` translations. Nothing can be defined on the API or configured on Grape afterwards -- both answer a write with an error from then on. It returns the API class, so `run MyAPI.finalize!` reads as one step. + +Serving from Ractors is the server's job; Grape's part is being ready for it. A server that runs the app in the main Ractor serves a finalized API just as well. + +What the mode gives up: + +* **Instrumentation.** `ActiveSupport::Notifications` keeps its notifier where a non-main Ractor cannot read it, so the `*.grape` hook points are skipped. +* **Per-request locale.** I18n keeps its configuration in class variables, which a non-main Ractor may not read at all. Messages come from a frozen snapshot of the locale that was default when `finalize!` ran. +* **File uploads.** Ruby's `Tempfile` is a `Delegator`, and a delegated method cannot be called from a non-main Ractor, so a multipart request carrying a file cannot be parsed there. +* **Reloading.** A finalized API cannot be changed, so reloading in development is out. + ## Contributing to Grape Grape is work of hundreds of contributors. You're encouraged to submit pull requests, propose features and discuss issues. diff --git a/lib/grape.rb b/lib/grape.rb index 200614f66..ca17d08fb 100644 --- a/lib/grape.rb +++ b/lib/grape.rb @@ -55,6 +55,8 @@ module Grape extend Dry::Configurable + @ractor = false + setting :param_builder, default: :hash_with_indifferent_access setting :lint, default: false setting :warn_on_helper_overrides, default: false @@ -102,6 +104,30 @@ module Grape # a method deprecated through this deprecator announced a removal version # that had already shipped, and every custom `behavior` lambda -- how a Rails # app consumes deprecations -- was handed the same wrong number. + # Ractor mode. A Grape API is defined and compiled in the main Ractor -- a + # non-main one may not write a class instance variable at all, which is where + # the DSL keeps everything -- and is then frozen whole and served from as + # many Ractors as the application wants (see Grape::API::Instance.finalize!). + # + # This has to be on before the API classes load, because it decides how a + # route block is turned into a method: Ruby refuses to call a method defined + # from a Proc that is not shareable from another Ractor, so in this mode the + # blocks are isolated as they are read. Turn it on in an initializer, above + # the requires that define the API. + # + # Needs a Ruby that can make a Method object shareable, which is Ruby 4.0 + # and later; on an earlier one this raises rather than letting the failure + # surface later, out of the middle of a frozen object graph. + def self.ractor! + raise Grape::Exceptions::RactorModeUnsupported unless Grape::Util::Shareable.supported? + + @ractor = true + end + + def self.ractor? + @ractor + end + def self.deprecator @deprecator ||= ActiveSupport::Deprecation.new("#{Gem::Version.new(VERSION).segments.first + 1}.0", 'Grape') end diff --git a/lib/grape/api.rb b/lib/grape/api.rb index 10c587fc7..4a97dac14 100644 --- a/lib/grape/api.rb +++ b/lib/grape/api.rb @@ -12,7 +12,7 @@ class API # protected methods too, so it has to be named here for {.override_all_methods!} # to leave it alone. +base+ is read on the API class itself whenever a mount # is refreshed, so recording it would refresh every mount below it again. - NON_OVERRIDABLE = %i[base base= base_instance? call change! configuration compile! inherit_settings recognize_path reset! routes top_level_setting].freeze + NON_OVERRIDABLE = %i[base base= base_instance? call change! configuration compile! finalize! inherit_settings recognize_path reset! routes top_level_setting].freeze # DSL methods that answer a setting when called with nothing to set -- no # argument, keyword or block -- and change none. Such a call is a read, and diff --git a/lib/grape/api/instance.rb b/lib/grape/api/instance.rb index c6ff3a2be..db00a58b7 100644 --- a/lib/grape/api/instance.rb +++ b/lib/grape/api/instance.rb @@ -69,6 +69,23 @@ def recognize_path(path) compile!.router.recognize_path(path) end + # Compile this API and make the compiled app shareable, so that it can + # be served from non-main Ractors -- the whole object graph behind it + # is frozen, and so is the process-wide state a request reads (see + # Grape::Util::Shareable). + # + # Nothing may be defined on the API, or configured on Grape, after + # this: both answer a write with a FrozenError from here on. Returns + # the API class it was called on, so a rackup file can + # +run MyAPI.finalize!+. + def finalize! + raise Grape::Exceptions::RactorModeNotEnabled unless Grape.ractor? + + Grape::Util::Shareable.freeze_globals! + ::Ractor.make_shareable(compile!) + base || self + end + # Wipe the compiled API so we can recompile after changes were made. def change! @instance = nil diff --git a/lib/grape/endpoint.rb b/lib/grape/endpoint.rb index 60d31b064..59a61659d 100644 --- a/lib/grape/endpoint.rb +++ b/lib/grape/endpoint.rb @@ -49,6 +49,12 @@ class << self def block_to_unbound_method(block) return unless block + # In Ractor mode the block has to be isolated before it becomes a + # method: Ruby refuses to call a method defined from an unshareable + # Proc from another Ractor. A block that reads an outer variable + # holding something unshareable cannot be isolated, and says so here, + # as the API class loads, rather than on the first request. + ::Ractor.make_shareable(block) if Grape.ractor? define_method :temp_unbound_method, block method = instance_method(:temp_unbound_method) remove_method :temp_unbound_method @@ -262,24 +268,28 @@ def options? # directly (no added allocations); the block is forwarded anonymously so # nothing is allocated unless a subscriber is present. def instrument_run(&) + return yield if Grape.ractor? return yield unless ActiveSupport::Notifications.notifier.listening?('endpoint_run.grape') ActiveSupport::Notifications.instrument('endpoint_run.grape', endpoint: self, env:, &) end def instrument_render(&) + return yield if Grape.ractor? return yield unless ActiveSupport::Notifications.notifier.listening?('endpoint_render.grape') ActiveSupport::Notifications.instrument('endpoint_render.grape', endpoint: self, &) end def instrument_run_validators(validators, request, &) + return yield if Grape.ractor? return yield unless ActiveSupport::Notifications.notifier.listening?('endpoint_run_validators.grape') ActiveSupport::Notifications.instrument('endpoint_run_validators.grape', endpoint: self, validators:, request:, &) end def instrument_run_filters(filters, type, &) + return yield if Grape.ractor? return yield unless ActiveSupport::Notifications.notifier.listening?('endpoint_run_filters.grape') ActiveSupport::Notifications.instrument('endpoint_run_filters.grape', endpoint: self, filters:, type:, &) diff --git a/lib/grape/exceptions/ractor_mode_not_enabled.rb b/lib/grape/exceptions/ractor_mode_not_enabled.rb new file mode 100644 index 000000000..8c04ed8af --- /dev/null +++ b/lib/grape/exceptions/ractor_mode_not_enabled.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Grape + module Exceptions + class RactorModeNotEnabled < Base + def initialize + super(message: compose_message(:ractor_mode_not_enabled)) + end + end + end +end diff --git a/lib/grape/exceptions/ractor_mode_unsupported.rb b/lib/grape/exceptions/ractor_mode_unsupported.rb new file mode 100644 index 000000000..c626a5a85 --- /dev/null +++ b/lib/grape/exceptions/ractor_mode_unsupported.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Grape + module Exceptions + class RactorModeUnsupported < Base + def initialize + super(message: compose_message(:ractor_mode_unsupported, ruby_version: RUBY_VERSION)) + end + end + end +end diff --git a/lib/grape/locale/en.yml b/lib/grape/locale/en.yml index 160a32853..124978cc4 100644 --- a/lib/grape/locale/en.yml +++ b/lib/grape/locale/en.yml @@ -44,6 +44,8 @@ en: summary: 'when version using header, you must specify :vendor option' mutual_exclusion: 'are mutually exclusive' oneof: 'does not match any of the allowed schemas' + ractor_mode_unsupported: 'Ractor mode needs a Ruby that can make a Method shareable, which is 4.0 and later; this is %{ruby_version}' + ractor_mode_not_enabled: 'finalize! needs Ractor mode, which has to be turned on with Grape.ractor! before the API classes load' presence: 'is missing' regexp: 'is invalid' same_as: 'is not the same as %{parameter}' diff --git a/lib/grape/middleware/formatter.rb b/lib/grape/middleware/formatter.rb index 5287a16d0..073e3ecd0 100644 --- a/lib/grape/middleware/formatter.rb +++ b/lib/grape/middleware/formatter.rb @@ -13,6 +13,12 @@ def initialize(content_types: nil, default_format: :txt, format: nil, formatters ALL_MEDIA_TYPES = '*/*' + # Rack leaves its own copy of this table unfrozen, in a constant a + # non-main Ractor may not read (see Grape::Util::Shareable), and every + # response reads it. A copy of Rack's, frozen, so that Grape neither + # reaches for Rack's nor has to freeze a constant it does not own. + STATUS_WITH_NO_ENTITY_BODY = Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.dup.freeze + # The query param that names the format. See #format_from_query. FORMAT_PARAM = 'format' @@ -98,7 +104,7 @@ def after status, headers, bodies = @app_response - return [status, headers, []] if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.include?(status) + return [status, headers, []] if STATUS_WITH_NO_ENTITY_BODY.include?(status) build_formatted_response(status, headers, bodies) end @@ -134,6 +140,7 @@ def build_formatted_response(status, headers, bodies) # notification machinery are skipped and the block runs directly (no added # allocations); the block is forwarded anonymously. def instrument_format_response(formatter, &) + return yield if Grape.ractor? return yield unless ActiveSupport::Notifications.notifier.listening?('format_response.grape') ActiveSupport::Notifications.instrument('format_response.grape', formatter:, env:, &) diff --git a/lib/grape/request.rb b/lib/grape/request.rb index e9b0810d9..fd9f28ed1 100644 --- a/lib/grape/request.rb +++ b/lib/grape/request.rb @@ -143,11 +143,25 @@ class Request < Rack::Request alias rack_params params alias rack_cookies cookies + class << self + # The query parser every Grape request parses its query string with, or + # nil to leave Rack to its own default. Ractor mode sets it (see + # Grape::Util::Shareable), because Rack keeps its default in a class + # instance variable, which a non-main Ractor may not read. + attr_accessor :query_parser + end + def initialize(env, build_params_with: nil) super(env) @build_params_with = build_params_with end + # Rack answers +@query_parser+ first and falls back to its own default, so + # this only steps in front of that fallback. + def query_parser + Grape::Request.query_parser || super + end + def params @params ||= make_params end diff --git a/lib/grape/util/shareable.rb b/lib/grape/util/shareable.rb new file mode 100644 index 000000000..645b51e23 --- /dev/null +++ b/lib/grape/util/shareable.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module Grape + module Util + # Everything a process has to settle once before a compiled API can be + # served from non-main Ractors, which may read no unshareable value a + # class, a module or a constant holds. + # + # Each step freezes state that is written while an application boots and + # only read once it serves, so freezing it costs a booted process nothing + # -- and says so loudly if something writes to it afterwards. + module Shareable + FALLBACK_LOCALE = :en + private_constant :FALLBACK_LOCALE + + # Whole modules rather than named constants and readers, so that a table + # added upstream is covered too, and because which ones a given + # application reaches is not knowable by reading: +Rack::Utils+ is here + # for +SYMBOL_TO_STATUS_CODE+, which nothing touches until an endpoint + # answers +status :created+, and for +ESCAPE_HTML+, which only an HTML + # error response reads, and only on Rack 2.2. + # + # Builder backs +to_xml+, and is loaded before they are frozen because + # the XML formatter would otherwise first reach for it from a Ractor, + # and find its tables unfrozen. +Rack::Multipart::Parser+ is here + # although an upload cannot work in a Ractor at all, so that it fails + # where the reason is (Ruby's Tempfile is a Delegator) rather than on a + # constant this could have frozen. + DEPENDENCY_GLOBALS = %w[ + Rack::Utils Rack::QueryParser Rack::Request::Helpers Rack::Headers Rack::Multipart::Parser + ActiveSupport::XmlMini Builder::XChar JSON MultiJson MultiJSON MultiXml + ].freeze + private_constant :DEPENDENCY_GLOBALS + + module_function + + # Ruby 3.3 and 3.4 refuse to make a Method or an UnboundMethod shareable, + # and an API is full of them: every endpoint holds its route block as one + # (Endpoint#source), and dry-types builds its coercers out of them. So + # nothing can be finalized there, whatever the API looks like. + def supported? + ::Ractor.make_shareable(Object.instance_method(:itself)) + true + rescue ::Ractor::Error + false + end + + def freeze_globals! + freeze_config! + freeze_dependency_globals! + snapshot_translations! + end + + # dry-configurable memoizes a setting the first time it is read, so a + # config frozen before that raises FrozenError on the first read instead + # of answering it. Read every setting first and the memo is complete, + # which is what makes the frozen config readable at all -- from any + # Ractor, from then on. + def freeze_config! + return if Grape.config.frozen? + + Grape.config._settings.each { |setting| Grape.config[setting.name] } + ::Ractor.make_shareable(Grape.config) + end + + # The tables and options a request reads out of Grape's dependencies, + # which a non-main Ractor may not touch while they are mutable. Both + # places they are kept: constants, such as Rack's media type lists, and + # class instance variables, such as Rack's default query parser or the + # json gem's +dump_default_options+, which every JSON response reads + # before json 3. Each is filled as its library loads and only read + # afterwards, so freezing it costs a booted process nothing -- upstream + # candidates, all. + def freeze_dependency_globals! + begin + require 'builder' + rescue LoadError + nil + end + + # Grape's own copy of Rack's default parser, frozen: Rack keeps its in a + # class instance variable, and this leaves that one alone. + Grape::Request.query_parser = ::Ractor.make_shareable(Rack::Utils.default_query_parser.dup) + + DEPENDENCY_GLOBALS.each do |name| + next unless Object.const_defined?(name) + + mod = Object.const_get(name) + mod.constants.each { |const| share { mod.const_get(const) } } + mod.instance_variables.each { |ivar| share { mod.instance_variable_get(ivar) } } + end + end + + # What cannot be read or frozen is what this never promised to cover: the + # request path reaches for tables and options, not for live objects, and + # a constant can also be one an optional file would have to define + # (JSON::GenericObject, for one, which +constants+ lists and +const_get+ + # then refuses). + def share + ::Ractor.make_shareable(yield) + rescue ::Ractor::Error, NameError + nil + end + + # I18n is out of reach entirely: its configuration lives in class + # variables, which a non-main Ractor may not even read. Take a frozen + # copy of the loaded translations instead, which {Translation} answers + # from in Ractor mode -- for the default locale, since choosing one per + # request is exactly what is out of reach. + # + # Only the +grape+ subtree is taken, which is everything Grape ever looks + # a message up under. The rest of a locale is not ours to freeze, and + # cannot be anyway -- ActiveSupport's own +en+ holds lambdas. + def snapshot_translations! + ::I18n.backend.__send__(:init_translations) unless ::I18n.backend.initialized? + + loaded = ::I18n.backend.translations + locale = loaded.key?(::I18n.default_locale) ? ::I18n.default_locale : FALLBACK_LOCALE + subtree = loaded.dig(locale, :grape) || loaded.dig(FALLBACK_LOCALE, :grape) + @translations = ::Ractor.make_shareable({ grape: subtree.deep_dup }) + end + + # The frozen translations for the locale that was default when the + # process was finalized, or nil before that. + def translations + @translations + end + end + end +end diff --git a/lib/grape/util/translation.rb b/lib/grape/util/translation.rb index ef92ac987..8a2ba0079 100644 --- a/lib/grape/util/translation.rb +++ b/lib/grape/util/translation.rb @@ -17,6 +17,8 @@ module Translation # Callers must not pass unintended keyword arguments — any extra keyword # will silently become an I18n interpolation variable. def translate(key, default: MISSING, scope: 'grape.errors.messages', locale: nil, **) + return translate_from_snapshot(key, default:, scope:, **) if Grape.ractor? + i18n_opts = { default:, scope:, ** } i18n_opts[:locale] = locale if locale message = ::I18n.translate(key, **i18n_opts) @@ -28,6 +30,27 @@ def translate(key, default: MISSING, scope: 'grape.errors.messages', locale: nil ::I18n.translate(key, default: effective_default, scope:, locale: FALLBACK_LOCALE, **) end + # Ractor mode answers from the frozen translations taken when the process + # was finalized (see Grape::Util::Shareable), because I18n keeps its + # configuration in class variables, which a non-main Ractor may not read. + # The locale is the one that was default at that moment: choosing one per + # request is what this trades away. + def translate_from_snapshot(key, default:, scope:, **) + path = Array(scope).flat_map { |part| part.to_s.split('.') }.push(*key.to_s.split('.')).map!(&:to_sym) + message = Grape::Util::Shareable.translations&.dig(*path) + message = default.equal?(MISSING) ? path.join('.') : default if message.nil? + interpolate(message, **) + end + + # I18n accepts a message with either +%{name}+ or +%s+ placeholders + # and fills both; Ruby's format only knows the second, so the first is + # rewritten into it. + def interpolate(message, **options) + return message unless message.is_a?(String) && options.any? && message.match?(/%[{<]/) + + format(message.gsub(/%\{(\w+)\}/) { "%<#{::Regexp.last_match(1)}>s" }, **options) + end + def fallback_locale?(locale) (locale || ::I18n.locale) == FALLBACK_LOCALE end diff --git a/spec/grape/util/shareable_spec.rb b/spec/grape/util/shareable_spec.rb new file mode 100644 index 000000000..e7bfdf00e --- /dev/null +++ b/spec/grape/util/shareable_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'open3' + +describe Grape::Util::Shareable do + # What this module settles -- Grape's configuration, Rack's lookup tables, a + # snapshot of the translations -- is process-wide and frozen afterwards, + # while the rest of the suite goes on writing to all three. So every example + # that gets as far as finalize! runs in a process of its own. + def run(source) + Open3.capture3(RbConfig.ruby, '-W0', '-Ilib', '-e', source) + end + + # The script's own stderr is the only account of what went wrong inside it, + # so a failure carries it rather than leaving an empty stdout to compare. + def output_of(source) + stdout, stderr, status = run(source) + raise "the script exited #{status.exitstatus}:\n#{stderr}" unless status.success? + + stdout + end + + let(:api_source) do + <<~RUBY + require 'grape' + Grape.ractor! + + class RactorAPI < Grape::API + format :json + params { requires :name, type: String } + get('/hello') { { hello: params[:name] } } + + # A Symbol status is why Rack::Utils has to be settled too: nothing + # reads SYMBOL_TO_STATUS_CODE until an endpoint answers one. + get('/created') do + status :created + { ok: true } + end + end + + RactorAPI.finalize! + + def env_for(query) + { 'REQUEST_METHOD' => 'GET', 'PATH_INFO' => '/hello', 'QUERY_STRING' => query, + 'SERVER_NAME' => 'example.org', 'SERVER_PORT' => '80', 'HTTP_HOST' => 'example.org', + 'rack.url_scheme' => 'http', 'rack.input' => StringIO.new, 'SCRIPT_NAME' => '' } + end + + def answer_to(query) + RactorAPI.call(env_for(query)).then { |status, _headers, body| [status, body.to_a.join] } + end + RUBY + end + + describe '.supported?' do + it 'refuses to turn the mode on when this Ruby cannot support it' do + skip 'this Ruby can make a Method shareable' if described_class.supported? + + expect { Grape.ractor! }.to raise_error(Grape::Exceptions::RactorModeUnsupported, /4\.0 and later/) + end + end + + # Ruby 3.3 and 3.4 cannot make a Method shareable, and Grape.ractor! says so + # rather than leaving an API half-frozen, so the mode is moot there. + context 'when this Ruby can make a Method shareable' do + before { skip "#{RUBY_VERSION} cannot make a Method shareable" unless described_class.supported? } + + describe 'finalize!' do + it 'refuses to finalize before Ractor mode is on' do + expect { Class.new(Grape::API).finalize! }.to raise_error(Grape::Exceptions::RactorModeNotEnabled, /Grape.ractor!/) + end + + it 'answers the API class it was called on' do + stdout = output_of("#{api_source}\nputs RactorAPI.finalize!.equal?(RactorAPI)") + expect(stdout).to eq("true\n") + end + + it 'makes the compiled API shareable' do + stdout = output_of("#{api_source}\nputs Ractor.shareable?(RactorAPI.base_instance.compile!)") + expect(stdout).to eq("true\n") + end + + it 'leaves the configuration readable from a Ractor, and frozen' do + stdout = output_of("#{api_source}\nputs (Ractor.new { Grape.config[:param_builder] }.value)\nputs Grape.config.frozen?") + expect(stdout).to eq("hash_with_indifferent_access\ntrue\n") + end + end + + describe 'serving from a non-main Ractor' do + it 'answers a request' do + stdout = output_of("#{api_source}\nputs (Ractor.new { answer_to('name=ada').inspect }.value)") + expect(stdout).to eq("[200, \"{\\\"hello\\\":\\\"ada\\\"}\"]\n") + end + + it 'answers a status named by a Symbol' do + stdout = output_of("#{api_source}\nputs Ractor.new { RactorAPI.call(env_for('').merge('PATH_INFO' => '/created')).first }.value") + expect(stdout).to eq("201\n") + end + + it 'answers a validation error with the message the translations carry' do + stdout = output_of("#{api_source}\nputs (Ractor.new { answer_to('').inspect }.value)") + expect(stdout).to eq("[400, \"{\\\"error\\\":\\\"name is missing\\\"}\"]\n") + end + + it 'answers what the main Ractor answers' do + stdout = output_of("#{api_source}\nputs answer_to('name=ada') == (Ractor.new { answer_to('name=ada') }.value)") + expect(stdout).to eq("true\n") + end + + it 'answers the same from several Ractors at once' do + source = "#{api_source}\n" \ + "answers = 4.times.map { Ractor.new { 25.times.map { answer_to('name=ada') }.uniq } }.map(&:value)\n" \ + 'puts answers.flatten(1).uniq.inspect' + stdout = output_of(source) + expect(stdout).to eq("[[200, \"{\\\"hello\\\":\\\"ada\\\"}\"]]\n") + end + end + + describe 'a route block that cannot be isolated' do + it 'says so as the API class loads, naming the variable it captured' do + source = <<~RUBY + require 'grape' + Grape.ractor! + captured = { mutable: true } + Class.new(Grape::API) { get('/x') { captured } } + RUBY + _, stderr, status = run(source) + expect(status).not_to be_success + # Ruby 3.3 quotes the variable as `captured', 4.0 as 'captured'. + expect(stderr).to include('Ractor::IsolationError').and match(/variable .captured./) + end + end + end +end