From 3962cbbf26556dc99a2ce5b086d3cf6402e4ecda Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sun, 20 Sep 2026 23:27:15 +0200 Subject: [PATCH] Add Ractor mode A Grape API is defined and compiled in the main Ractor -- a non-main one cannot write a class instance variable at all, and that is where the DSL keeps everything -- so the only shape this can take is: define, compile, freeze, then serve from as many Ractors as the application wants. Grape.ractor! turns the mode on, before the API classes load, because it decides 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 the blocks are isolated as they are read, and one that captures something unshareable says so as its class loads. It needs Ruby 4.0 or later, and says so rather than leaving an API half-frozen: 3.3 and 3.4 cannot make a Method shareable, and an API is full of them -- every endpoint holds its route block as one, and dry-types builds its coercers out of them. MyAPI.finalize! then compiles the API, makes the whole graph behind it shareable, and settles the process-wide state a request reads: Grape's own configuration (read once per setting first, or dry-configurable's memoization would write to a frozen Hash), the tables and options Rack, ActiveSupport, Builder and the json gem fill as they load, and a snapshot of the grape translations, since I18n keeps its configuration in class variables that a non-main Ractor may not read at all. Instrumentation is skipped in this mode for the same reason. What it gives up is in the README: instrumentation, per-request locale, file uploads (Ruby's own Tempfile is a Delegator, whose methods cannot be called from a non-main Ractor) and reloading. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + README.md | 39 +++++ lib/grape.rb | 26 ++++ lib/grape/api.rb | 2 +- lib/grape/api/instance.rb | 17 +++ lib/grape/endpoint.rb | 10 ++ .../exceptions/ractor_mode_not_enabled.rb | 11 ++ .../exceptions/ractor_mode_unsupported.rb | 11 ++ lib/grape/locale/en.yml | 2 + lib/grape/middleware/formatter.rb | 9 +- lib/grape/request.rb | 14 ++ lib/grape/util/shareable.rb | 130 +++++++++++++++++ lib/grape/util/translation.rb | 23 +++ spec/grape/util/shareable_spec.rb | 134 ++++++++++++++++++ 14 files changed, 427 insertions(+), 2 deletions(-) create mode 100644 lib/grape/exceptions/ractor_mode_not_enabled.rb create mode 100644 lib/grape/exceptions/ractor_mode_unsupported.rb create mode 100644 lib/grape/util/shareable.rb create mode 100644 spec/grape/util/shareable_spec.rb 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