Module: RactorRailsShim
- Defined in:
- lib/ractor_rails_shim.rb,
lib/ractor_rails_shim/check.rb,
lib/ractor_rails_shim/version.rb,
lib/ractor_rails_shim/patches/core.rb,
lib/ractor_rails_shim/patches/rack.rb,
lib/ractor_rails_shim/version_check.rb,
lib/ractor_rails_shim/patches/devise.rb,
lib/ractor_rails_shim/patches/warden.rb,
lib/ractor_rails_shim/patches/openssl.rb,
lib/ractor_rails_shim/patches/kaminari.rb,
lib/ractor_rails_shim/patches/rubygems.rb,
lib/ractor_rails_shim/patches/propshaft.rb,
lib/ractor_rails_shim/patches/action_view.rb,
lib/ractor_rails_shim/patches/orm_adapter.rb,
lib/ractor_rails_shim/patches/url_helpers.rb,
lib/ractor_rails_shim/patches/activerecord.rb,
lib/ractor_rails_shim/patches/rails_module.rb,
lib/ractor_rails_shim/patches/route_helpers.rb,
lib/ractor_rails_shim/patches/active_support.rb,
lib/ractor_rails_shim/patches/make_shareable.rb,
lib/ractor_rails_shim/patches/mattr_accessor.rb,
lib/ractor_rails_shim/patches/action_dispatch.rb,
lib/ractor_rails_shim/patches/class_attribute.rb,
lib/ractor_rails_shim/patches/action_controller.rb,
lib/ractor_rails_shim/patches/execution_wrapper.rb,
lib/ractor_rails_shim/patches/zeitwerk_registry.rb,
lib/ractor_rails_shim/patches/polymorphic_routes.rb,
lib/ractor_rails_shim/patches/active_model_attribute.rb,
lib/ractor_rails_shim/patches/active_record_model_schema.rb
Overview
ActiveRecord caches a number of values in class-level ivars via @ivar ||=
(ModelSchema::ClassMethods). Several of these hold UNshareable values
(AttributeSet::Builder, AttributeSet::YAMLEncoder, Hashes of Attribute
objects) or take arguments, so neither pre-warming nor Ractor.make_shareable
makes them safe for worker Ractors: a worker reading the class ivar hits
Ractor::IsolationError (unshareable value) and writing it (the ||=) hits
"can not set instance variables of classes/modules by non-main Ractors".
We redirect these specific caches to per-Ractor storage (ActiveSupport::IsolatedExecutionState), keyed by the class. Each worker Ractor computes and keeps its OWN copy — it never touches the shared class ivar, so there is no cross-boundary (unshareable) value and no class-ivar write. The values are deterministic from the schema/connection, so per-Ractor caching is behavior-preserving. In the main Ractor this behaves identically (compute once, cache).
Defined Under Namespace
Modules: ActiveModelAttributePatch, ActiveModelAttributeRegistrationPatch, ActiveRecordAttributesPatch, ActiveRecordModelSchemaPatch, Version Classes: ArWorkerInitWrapper, Check, UnsupportedVersionError, WorkerApp
Constant Summary collapse
- VERSION =
"0.2.0"- KEYS =
The keys under which each global is stored in IsolatedExecutionState. Namespaced to avoid collisions with Rails' own uses of IES.
{ application: :ractor_rails_shim_application, app_class: :ractor_rails_shim_app_class, cache: :ractor_rails_shim_cache, logger: :ractor_rails_shim_logger, env: :ractor_rails_shim_env, backtrace_cleaner: :ractor_rails_shim_backtrace_cleaner }.freeze
- CLASS_ATTRIBUTES =
Registry of every class_attribute definition the shim's
redefinepatch has seen. Each entry is [owner_name, namespaced_name (Symbol), key (Symbol)] so that atprepare_for_ractors!time we can capture the main-ractor value of each attribute, make it shareable, and expose it as a read-only fallback for worker Ractors (whose own IES slot is empty). Without this, framework class config (ActionController::Base.config, etc.) is per-Ractor-nil in workers and request dispatch dies (e.g. default_static_extension -> config is nil). The fallback is ONLY read by workers; the main ractor keeps its own live (possibly mutable) value in its IES slot, untouched.The fallback table itself is built once, at prepare_for_ractors! time, and made shareable; workers read it via a constant (RactorRailsShim::SHAREABLE_FALLBACK).
[]
- ABSTRACT_REGISTRY =
Shareable registry: controller class → abstract? boolean. Populated at prepare_for_ractors! time by scanning all AbstractController::Base descendants for their @abstract ivar. Workers read this via the patched AbstractController::Base.abstract? (per-class values can't live in per-Ractor IES). Made shareable (frozen) at prepare time.
Ractor.make_shareable({})
- MATTR_DEFAULTS =
Runtime registry: mattr_accessor IES key → default value. Written at mattr-definition time (boot, in main). The mattr reader (string-eval'd) looks the default up here by key — we CANNOT inline arbitrary default values into the eval'd method body (a Logger's
.inspectis#<Logger:...>, invalid Ruby). Read by workers when both their IES slot and SHAREABLE_FALLBACK are empty. NOT made shareable (some defaults like Logger are intrinsically unshareable); workers only reach here if the value is a simple shareable literal (Symbol/String/Integer), in which case reading the constant is fine because... actually it IS a constant holding an unshareable Hash → worker read would raise. So this registry is ONLY safe to read from workers for shareable defaults. We guard the reader to only consult it for defaults that are Ractor.shareable?. {}
- CLASS_ATTR_VALUES =
class_attribute default values, keyed by IES key. Written at class_attribute-definition time (boot, in main). The class_attribute reader falls back to this in the MAIN ractor when the IES slot is empty (which it is on non-boot threads — IES is thread-local, and Puma's request threads have empty slots). NOT made shareable (values may be mutable Hashes/Arrays); only safe to read from the main ractor. Workers use SHAREABLE_FALLBACK (built at prepare_for_ractors! time) instead.
{}
- SHAREABLE_MATTR_DEFAULTS =
Shareable subset of MATTR_DEFAULTS: only defaults that are Ractor.shareable? (so workers can read the constant safely). Written at mattr-definition time (boot, in main, before workers spawn); frozen + made shareable at prepare_for_ractors! time.
{}
- SHAREABLE_CONSTANTS =
Registry of constant path strings ("A::B::C") whose values are mutable Arrays/Hashes/Sets that need to be made shareable (deep-frozen) at boot. Each per-concern file concats its own constants into this array. Users can add their own via RactorRailsShim.shareable_constants << "MyGem::LIST".
[]
- SHAREABLE_CLASS_IVARS =
Registry of [ClassName, :ivar] pairs: class-level instance variables whose values are mutable (Hashes/Arrays/objects) and must be made Ractor-shareable (deep-frozen) at boot so worker Ractors can read them. Unlike SHAREABLE_CONSTANTS (top-level constants), these are class instance variables (e.g. ActiveSupport::Editor.@editors, Warden::Strategies.@strategies) that hold unshareable values and are read during request dispatch.
[]
- VIEW_CONTEXT_REGISTRY =
Shareable registry: controller class → its built view_context_class. Populated at prepare_for_ractors! time by calling view_context_class on each loaded controller in main (build_view_context_class uses Class.new... blocks → un-shareable Proc from a worker). Made shareable.
Ractor.make_shareable({})
- SHAREABLE_FALLBACK =
Frozen, shareable fallback table for class_attribute / mattr_accessor values. Built once at prepare_for_ractors! time from the main ractor's live values (class_attribute IES slot / mattr @@sym), each made shareable via callable-replacement + make_shareable. Workers read this via the RactorRailsShim::SHAREABLE_FALLBACK constant when their own IES slot is empty. Values that can't be made shareable are skipped (workers see nil for those and must set their own).
Ractor.make_shareable({})
- PATCH_VERSIONS =
Versions each patch was tested against. Populated by the install_* methods as they register themselves. A patch applies to a runtime Rails version only if the runtime segment matches one of its tested entries. This is the "load different patches for different Rails versions" infrastructure: to add 7.x support, write version-specific variants and tag them here. Defined on the module (not the singleton class) so it's readable from outside as RactorRailsShim::PATCH_VERSIONS.
{}
- SUPPORTED_RUBY =
RactorRailsShim::Version::SUPPORTED_RUBY
- SUPPORTED_RAILS =
"8.1"- FILES_LOC =
Source-location constant used by make_app_shareable!'s proc-replacement graph traversal (moved here from make_shareable.rb so the Rack concern's pieces live together).
"/rack/files.rb".freeze
- DEVISE_SCOPE_LOC =
Source-location constant for the Devise scope constraint Proc (moved from make_shareable.rb so the Devise-related callable lives with the Devise patch).
"/devise/rails/routes.rb".freeze
- SHAREABLE_COMPILED_MODULE =
Shareable, mutable module that holds compiled template methods (e.g.
_app_views_...). ActionView attaches compiled template methods tocompiled_method_container; the default returns a per-class container, which isolates the shared application layout's compiled method to whichever controller first rendered it (so other controllers raise NoMethodError on the layout). Routing every view_context_class to this ONE shared module makes compiled methods available to all controllers/workers. It is a plain Module (shareable without freezing, so workers can still define methods on it). Module.new
- AR_PRIMARY_KEYS_SHAREABLE =
Shareable snapshot of each AR model class's primary_key, captured at prepare time. Workers read this instead of the raw @primary_key class ivar (which is initialized to PRIMARY_KEY_NOT_SET, a BasicObject that can't be made shareable). Populated by _share_model_classes! in the main ractor.
Ractor.make_shareable({})
- PgBindBlock =
Shareable callable that replaces Arel::Visitors::PostgreSQL::BIND_BLOCK (a Proc
proc { |i| "$#{i}" }). Callable cross-Ractor. Ractor.make_shareable(Object.new.tap do |o| def o.call(i); "$#{i}"; end def o.to_proc; method(:call).to_proc; end end)
- SqlBindBlock =
Shareable callable that replaces Arel::Visitors::ToSql::BIND_BLOCK (a Proc
proc { "?" }). Callable cross-Ractor. Ractor.make_shareable(Object.new.tap do |o| def o.call(_i = nil); "?"; end def o.to_proc; method(:call).to_proc; end end)
- AR_CONFIGURATIONS_SNAPSHOT =
Shareable snapshot of ActiveRecord::Base.configurations at prepare_for_ractors! time. Workers read this to establish their own connection pools with the same db config. Made shareable (frozen).
nil- AR_CONFIGURATIONS_SHAREABLE =
Shareable (deep-frozen) copy of ActiveRecord::Base.configurations (the DatabaseConfigurations object) captured at prepare time. Workers read this instead of the raw
@@configurationsclass variable, which a non-main Ractor cannot access. nil- AR_DB_CONFIG_HANDLERS_SHAREABLE =
Shareable (deep-frozen) copy of DatabaseConfigurations.db_config_handlers (an Array of shareable handler Procs) captured at prepare time. Workers read this instead of the per-Ractor class instance variable.
nil- AR_QUERY_TRANSFORMERS_SHAREABLE =
Shareable (deep-frozen) copy of ActiveRecord.query_transformers (an Array of transformer classes/objects) captured at prepare time. Workers read this instead of the per-Ractor class instance variable.
nil- SSL_LOC =
Source-location constants used by make_app_shareable!'s proc-replacement graph traversal (moved here from make_shareable.rb so each concern's pieces live together).
"/active_dispatch/middleware/ssl.rb".freeze
- COOKIE_LOC =
"/session/cookie_store.rb".freeze
- MAPPER_LOC =
"/action_dispatch/routing/mapper.rb".freeze
Class Attribute Summary collapse
-
._abstract_registry ⇒ Object
Accessor for the abstract-controller registry (written by abstract! in main, read by abstract? in workers).
-
._view_context_fallback ⇒ Object
Returns the value of attribute _view_context_fallback.
-
._view_context_registry ⇒ Object
Returns the value of attribute _view_context_registry.
-
.thread_mode ⇒ Object
writeonly
Sets the attribute thread_mode.
-
.version_policy ⇒ Object
Policy for version mismatches.
Class Method Summary collapse
-
._build_shareable_fallback! ⇒ Object
Build the shareable fallback for every class_attribute / mattr_accessor value the shim has rerouted.
- ._capture_ar_configurations! ⇒ Object
-
._check_version_support ⇒ Object
The shim's patches target specific Rails 8.1 class layouts and Ruby 4.0 Ractor semantics.
- ._collect_controller_classes(app) ⇒ Object
- ._collect_procs(app) ⇒ Object
-
._devise_mapping_replacement(proc_obj, _parent) ⇒ Object
Build a shareable replacement for a Devise scope constraint.
-
._find_files_server(mw) ⇒ Object
Find the Rack::Files (asset) server in the middleware chain, used by make_app_shareable! when replacing the Rack::Head#@app lambda (whose binding receiver is the Rack::Files instance).
-
._freeze_active_record_class_ivars! ⇒ Object
Freeze (make Ractor-shareable) every instance variable on every ActiveRecord model class in the MAIN Ractor, before the graph is frozen.
-
._freeze_class_ivars!(owner) ⇒ Object
Make every unshareable class ivar on
ownershareable (deep-freeze) and write it back. - ._freeze_declared_callbacks! ⇒ Object
-
._freeze_global_class_ivars! ⇒ Object
Freeze (make Ractor-shareable) unshareable class-level ivars on GLOBAL classes (Time/Date timezone caches, I18n locale caches, ...) in the MAIN Ractor, before the graph is frozen.
-
._freeze_global_constants! ⇒ Object
Replace GLOBAL constants that hold non-shareable values (e.g. Time/Date/DateTime::DATE_FORMATS contain Proc values) with frozen, shareable equivalents so worker Ractors can read them.
-
._freeze_journey_visitors! ⇒ Object
Journey's routing visitors are stored as instance singletons in class constants (e.g.
ActionDispatch::Journey::Visitors::Each::INSTANCE). -
._freeze_messages_constants! ⇒ Object
ActiveSupport::Messages::Metadata holds non-shareable Array constants (ENVELOPE_SERIALIZERS / TIMESTAMP_SERIALIZERS) of serializer Modules, used by MessageEncryptor during flash/session cookie serialization.
-
._freeze_mime_negotiation! ⇒ Object
ActionDispatch::Http::MimeNegotiation holds module-level constants (e.g. RESCUABLE_MIME_FORMAT_ERRORS, an Array of exception classes) that are referenced from the request path (params_readable? ->
rescue * RESCUABLE_MIME_FORMAT_ERRORS). -
._freeze_shareable_class_ivars! ⇒ Object
Capture each controller's OWN declared
process_actionsymbol filters (before_action / after_action) so worker Ractors can replay them. -
._generate_ar_attribute_methods! ⇒ Object
Force AR attribute-method generation for every loaded model in the MAIN Ractor.
-
._install_abstract_controller_patch ⇒ Object
Patch AbstractController::Base.controller_path to not write/read the action_methods, clear_action_methods!, abstract!, abstract?, and _prefixes to route through IES or use the shareable fallback.
-
._install_action_controller_controller_name_patch ⇒ Object
Patch ActionController::Metal.controller_name (a class method).
-
._install_action_dispatch_http_url_patch ⇒ Object
ActionDispatch::Http::URL reads the
tld_lengthclass variable DIRECTLY (@@tld_length) innormalize_hostand in the default-parameter ofdomain/subdomains/subdomain. -
._install_action_dispatch_mounted_helpers_patch ⇒ Object
Patch ActionDispatch::Routing::RouteSet::MountedHelpers#main_app (and its _main_app worker).
-
._install_action_dispatch_routing_patch ⇒ Object
Patch ActionDispatch::Routing::RouteSet URL generation.
-
._install_action_view_field_type_patch ⇒ Object
Patch ActionView::Helpers::Tags::TextField.field_type (and the subclasses EmailField/PasswordField/... that inherit it).
-
._install_action_view_partial_path_patch ⇒ Object
Patch ActionView::AbstractRenderer::ObjectRendering#partial_path.
-
._install_action_view_resolver_patch ⇒ Object
Patch ActionView::FileSystemResolver#_find_all.
-
._install_action_view_safe_join_patch ⇒ Object
Patch ActionView::Helpers::OutputSafetyHelper#safe_join.
-
._install_active_model_attribute_patch ⇒ Object
See patches/active_model_attribute.rb.
-
._install_active_model_conversion_patch ⇒ Object
Patch ActiveModel::Conversion::ClassMethods#_to_partial_path to route its lazy class-ivar cache (
@_to_partial_path ||= ...) through IsolatedExecutionState. -
._install_active_model_naming_patch ⇒ Object
(an ActiveModel::Name holding unfrozen, unshareable Strings) on the model class.
-
._install_active_record_core_patch ⇒ Object
Patch ActiveRecord::Core::ClassMethods#arel_table / #predicate_builder / #type_caster.
-
._install_active_record_inheritance_patch ⇒ Object
Patch ActiveRecord::Inheritance::ClassMethods#finder_needs_type_condition?.
-
._install_active_record_model_schema_patch ⇒ Object
Patch ActiveRecord::ModelSchema::ClassMethods so worker Ractors do not write the
@table_name(and related) class ivars on the shared model class. -
._install_active_support_error_reporter_patch ⇒ Object
Patch ActiveSupport module's @error_reporter class ivar (defined via
singleton_class.attr_accessor :error_reporterin active_support.rb:109) to not read from a worker Ractor. -
._install_activerecord_configurations_patch ⇒ Object
Patch ActiveRecord::Core.configurations / configurations= to route the raw
@@configurationsclass variable (which a non-main Ractor cannot read or write) through IsolatedExecutionState, with a shareable (deep-frozen) fallback for worker Ractors. -
._install_activerecord_connection_handler_patch ⇒ Object
Patch ActiveRecord::Base to route default_connection_handler through IES, and ensure connection_handler returns the per-Ractor handler.
-
._install_activerecord_db_config_handlers_patch ⇒ Object
Patch ActiveRecord::DatabaseConfigurations.db_config_handlers (a
singleton_class.attr_accessor, i.e. a class instance variable on the DatabaseConfigurations class) to route through IES with a shareable fallback. -
._install_activerecord_deduplicable_patch ⇒ Object
Patch Deduplicable::ClassMethods#registry to route the lazy class instance variable @registry through IES.
-
._install_activerecord_delegation_patch ⇒ Object
Patch ActiveRecord::Delegation.uncacheable_methods to route the lazy class instance variable @uncacheable_methods through IES.
-
._install_activerecord_find_by_cache_patch ⇒ Object
ActiveRecord::Base#cached_find_by_statementreads@find_by_statement_cache[connection.prepared_statements](a Hash whose values areConcurrent::Maps) and callscache.compute_if_absent(key). -
._install_activerecord_migration_patch ⇒ Object
Patch ActiveRecord::Migrator.migrations_paths (a singleton attr_accessor reading the
@migrations_pathsclass ivar) and ActiveRecord::Migration::CheckPending (the dev pending-migration middleware) to be Ractor-safe. -
._install_activerecord_model_classes_patch ⇒ Object
Register + run the model-class shareability patch (Blocker: AR model class lazy class-ivar initialization from workers).
-
._install_activerecord_model_schema_patch ⇒ Object
Patch ActiveRecord::ModelSchema::ClassMethods lazy class-ivar caches (
symbol_column_to_string,content_columns,column_defaults) to route through IsolatedExecutionState. -
._install_activerecord_module_attrs_patch ⇒ Object
Patch ActiveRecord module-level singleton_class.attr_accessor attributes (schema_cache_ignored_tables, database_cli, etc.) to route through IES with shareable fallbacks.
-
._install_activerecord_pool_config_patch ⇒ Object
Patch ActiveRecord::ConnectionAdapters::PoolConfig#initialize to skip writing to the INSTANCES ObjectSpace::WeakMap registry in non-main Ractors.
-
._install_activerecord_primary_key_patch ⇒ Object
Patch ActiveRecord::AttributeMethods::PrimaryKey#primary_key and #composite_primary_key? to not read the PRIMARY_KEY_NOT_SET constant.
-
._install_activerecord_query_constraints_patch ⇒ Object
Patch Persistence::ClassMethods#query_constraints_list and #has_query_constraints? to route the lazy @query_constraints_list class ivar through IES.
-
._install_activerecord_query_logs_patch ⇒ Object
Patch ActiveRecord::QueryLogs#tag_content.
-
._install_activerecord_query_transformers_patch ⇒ Object
Patch ActiveRecord.query_transformers to route through IES with a shareable fallback.
-
._install_activerecord_quoting_cache_patch ⇒ Object
Patch the adapter quoting caches (QUOTED_COLUMN_NAMES / QUOTED_TABLE_NAMES) to use per-Ractor storage instead of the unshareable
Concurrent::Mapconstants. -
._install_activerecord_reaper_patch ⇒ Object
Patch ConnectionPool::Reaper#run to no-op in non-main Ractors.
-
._install_activerecord_relation_delegate_cache_patch ⇒ Object
Blockers 3: ActiveRecord model classes cache relation-delegate classes in the
@relation_delegate_cacheclass instance variable (set inDelegateCache#initialize_relation_delegate_cache, activerecord/relation/delegation.rb:31-44). -
._install_activerecord_serialize_cast_value_patch ⇒ Object
Patch ActiveModel::Type::SerializeCastValue::ClassMethods #serialize_cast_value_compatible? to route the lazy class instance variable @serialize_cast_value_compatible through IES.
-
._install_activerecord_transaction_callbacks_patch ⇒ Object
In the shared :ractor graph, ActiveRecord model classes can end up with a nil
__callbacks(the class_attribute value can't be made shareable when it holds unshareable callback Procs, so the shim's shareable fallback returns nil). -
._install_all_framework_patches ⇒ Object
Public API: run after Rails.application.initialize! and BEFORE spawning worker Ractors.
- ._install_arel_bind_block_patch ⇒ Object
-
._install_arel_visitor_dispatch_cache_patch ⇒ Object
Patch Arel::Visitors::Visitor.dispatch_cache to route through IES.
-
._install_caching_key_generator_patch ⇒ Object
Patch ActiveSupport::CachingKeyGenerator#generate_key.
-
._install_callback_declaration_capture! ⇒ Object
Install an interceptor on ActiveSupport::Callbacks.set_callback that records, per declaring class, every symbolic
:process_actionfilter it declares. -
._install_callbacks_nil_safe_patch ⇒ Object
Patch ActiveSupport::Inflector::Inflections to not read @en_instance / @instance class ivars from a worker Ractor.
-
._install_csrf_reset_patch ⇒ Object
In the shared :ractor graph, Devise's engine controllers (e.g. Devise::SessionsController) end up with a nil
csrf_token_storage_strategyat request time — the value is dropped when make_app_shareable! deep-freezes the app (RequestForgeryProtection sets it only on ActionController::Base.config, and the per-controller frozen config copy loses it). -
._install_devise_authenticatable_patch ⇒ Object
Devise::Models::Authenticatable::ClassMethods#devise_parameter_filter memoizes
@devise_parameter_filter ||= Devise::ParameterFilter.new(...)on the MODEL CLASS. -
._install_devise_failure_app_patch ⇒ Object
Devise::FailureApp.call memoizes its Rack endpoint in a class-level ivar (
@respond ||= action(:respond)). - ._install_devise_url_helpers_patch ⇒ Object
-
._install_exception_wrapper_patch ⇒ Object
Patch ActionDispatch::ExceptionWrapper instance methods that read @@rescue_responses / @@rescue_templates class variables directly (bypassing the mattr_accessor reader the shim already reroutes through IES).
-
._install_execution_context_patch ⇒ Object
class ivar and nestable are read/written per-request.
-
._install_flash_helpers_patch ⇒ Object
Patch the flash-type helper methods (
notice,alert, ...) defined byActionController::Metal::Flash#add_flash_typesviadefine_method(type) { request.flash[type] }. -
._install_hash_compute_if_absent_patch ⇒ Object
The shim's make_app_shareable! replaces Concurrent::Map instance variables (which are not Ractor-shareable) with plain Hashes so workers can read them.
-
._install_i18n_backend_patch ⇒ Object
Patch I18n::Backend::Simple::Implementation#translations and #store_translations.
-
._install_i18n_interpolation_patch ⇒ Object
Patch I18n.interpolate_hash.
-
._install_i18n_patch ⇒ Object
Patch I18n::Config's class-variable-backed accessors (default_locale, locale, backend, etc.) to not read @@cvars from a worker Ractor.
- ._install_inflector_patch ⇒ Object
-
._install_journey_routes_patch ⇒ Object
Make Journey route recognition work under kino :ractor.
-
._install_json_encoding_patch ⇒ Object
Patch ActiveSupport::JSON::Encoding.
- ._install_kaminari_config_patch ⇒ Object
-
._install_local_cache_patch ⇒ Object
Patch ActiveSupport::Cache::Strategy::LocalCache#local_cache_key.
-
._install_log_subscriber_patch ⇒ Object
Patch ActiveSupport::LogSubscriber.logger — a raw class ivar with lazy init (@logger ||= Rails.logger) that's WRITTEN at request teardown via flush_all!.
- ._install_lookup_context_patch ⇒ Object
-
._install_messages_serializer_patch ⇒ Object
Patch ActiveSupport::Messages::SerializerWithFallback.
-
._install_module_introspection_patch ⇒ Object
Patch Module#module_parent_name so a worker Ractor does not write the
@parent_nameclass ivar on a shared (non-frozen) module. -
._install_notifications_notifier_patch ⇒ Object
Patch ActiveSupport::Notifications.notifier to not read the @notifier class ivar from a worker Ractor.
- ._install_openssl_digest_patch ⇒ Object
- ._install_orm_adapter_patch ⇒ Object
-
._install_parameter_encoding_patch ⇒ Object
Patch ActionController::ParameterEncoding::ClassMethods#action_encoding_template to not read @_parameter_encodings (a raw class ivar) from a worker Ractor.
-
._install_path_registry_patch ⇒ Object
Patch ActionView::PathRegistry to not read its raw class ivars (@view_paths_by_class, @file_system_resolvers) from a worker Ractor.
- ._install_polymorphic_routes_patch ⇒ Object
- ._install_propshaft_patch ⇒ Object
-
._install_query_parser_patch ⇒ Object
ActionDispatch::QueryParser.each_pair returns
enum_for(:each_pair, s, separator)when called without a block. -
._install_rack_request_patch ⇒ Object
Patch Rack::Request's class-level attr_accessors (forwarded_priority, x_forwarded_proto_priority) to not read @ivars from a worker Ractor.
-
._install_rack_utils_patch ⇒ Object
Patch Rack::Utils singleton attr_accessors (default_query_parser, multipart_total_part_limit, multipart_file_limit) to not read @ivars from a worker Ractor.
-
._install_reloader_patch ⇒ Object
Patch ActiveSupport::Reloader#check! / #reloaded!.
-
._install_request_parameter_parsers_patch ⇒ Object
Patch ActionDispatch::Request.parameter_parsers (singleton attr_reader backed by @parameter_parsers) to not read the class ivar from a worker Ractor.
- ._install_template_handlers_patch ⇒ Object
- ._install_warden_hooks_patch ⇒ Object
-
._install_warden_serializer_patch ⇒ Object
Warden registers per-scope session serializers with
Warden::SessionSerializer.send(:define_method, method_name, &block)(warden-1.2.9/lib/warden/manager.rb:71). -
._install_warden_strategies_patch ⇒ Object
Patch Warden::Strategies#_strategies.
-
._install_with_empty_template_cache_patch ⇒ Object
Patch
ActionView::Base.with_empty_template_cache(action_view/base.rb:204) to a block-freedef. -
._introspectable?(o) ⇒ Boolean
BasicObject (and its subclasses) don't define respond_to?, so calling o.respond_to? on one raises NoMethodError.
-
._make_value_shareable(val) ⇒ Object
Best-effort shareable replacement for a constant value.
-
._neutralize_logger_io!(app) ⇒ Object
Detach the logger IO from the app graph so Ractor.make_shareable(app) doesn't freeze the process's real $stdout/$stderr.
- ._patch_rails_module_body(mod) ⇒ Object
-
._precompute_lazy_ivars(app) ⇒ Object
--- graph traversal helpers ---.
-
._precompute_propshaft!(app) ⇒ Object
Warm every Propshaft Assembly/LoadPath lazy ivar in MAIN (before the app is frozen).
-
._record_declared_callback(klass_id, kind, filter, only, except) ⇒ Object
Record a single declared symbolic filter.
-
._register_patch(name, *tested_segments) ⇒ Object
Record that a patch was developed/tested against the given Rails version segments.
-
._replace_locks_and_concurrent_maps!(app) ⇒ Object
NOTE:
_devise_mapping_replacement(Devise scope constraint → DeviseMappingCallable) now lives in warden.rb;_find_files_server(Rack::Files target for the asset stack) now lives in rack.rb. - ._replace_one_proc(proc_obj, parent, ivar, mw) ⇒ Object
-
._replace_unshareable_procs!(app) ⇒ Object
Replace every Proc in the app graph with a callable/no-op object.
-
._share_active_record_internals! ⇒ Object
ActiveRecord's internal helper classes (the *Clause classes used while building a relation's Arel) cache a frozen "empty" singleton in a class instance variable via
@empty ||= new(...).freeze(e.g. ActiveRecord::Relation::WhereClause#empty). -
._share_model_classes! ⇒ Object
ActiveRecord model classes lazily initialize many class instance variables on first use (e.g. @table_name, @arel_table, @predicate_builder, @columns_hash, @attribute_methods_module) via
@ivar ||= compute. -
._share_relation_delegate_caches! ⇒ Object
Make every loaded AR model class's @relation_delegate_cache shareable.
-
._shareable_copy(val) ⇒ Object
Return a fresh copy of a mutable default container (Hash/Array) so the fallback entry is independent.
-
._shareable_ivar_replacement(v) ⇒ Object
- Monitor/Mutex -> NoOpLock (never contended post-boot) - Concurrent::Map -> frozen Hash - else -> Ractor.make_shareable; if that fails (statement caches etc.), a frozen empty container of the same kind so the worker reads a shareable value (cold cache in workers; slower, correct).
-
._try_make_shareable(val, owner_name, attr_name, default: false) ⇒ Object
Best-effort attempt to make
valshareable (callable-replacement for Procs + lock-replacement + make_shareable). -
._version_mismatch(message) ⇒ Object
Apply the configured policy to a mismatch message.
-
._warm_active_record_class_caches! ⇒ Object
Warm ActiveRecord model classes' lazily-computed, shareable class-ivar memoizations in the MAIN Ractor, BEFORE the graph is frozen.
-
._warm_attribute_method_patterns! ⇒ Object
Build + freeze ActiveModel's per-class
attribute_method_patterns_cache(andattribute_method_matchers) in the MAIN Ractor for every loaded model. -
._warm_journey_routes! ⇒ Object
Warm + cache
@ast/@simulatoron the live Routes graph. -
._warm_path_patterns!(routes) ⇒ Object
Pre-compute every Journey::Path::Pattern's lazy memoized ivars (@required_names, @optional_names, @offsets, @re, @requirements_for_missing_keys_check) on the LIVE (unfrozen) pattern, before Ractor.make_shareable freezes the graph.
-
.applicable_patches ⇒ Object
Report which registered patches apply to the runtime Rails version (and which were skipped because they're untested on it).
-
.autoload_install! ⇒ Object
Require this gem and the patches auto-install IF Rails is loaded.
-
.capture_app_constants ⇒ Object
Capture a frozen name -> object map for every constant the application's Zeitwerk loaders manage.
-
.do_install_shareable_constants ⇒ Object
Run after Rails is fully booted (after Rails.application.initialize!) and BEFORE spawning worker Ractors.
-
.fix_url_helpers_singleton_routes ⇒ Object
The module-level singleton
_routes(set viaredefine_singleton_methodin the gem'sincludedblock) is still adefine_methodblock. -
.init_worker_ar_connections! ⇒ Object
Worker-Ractor hook: create a fresh ConnectionHandler and establish connections from the captured configurations snapshot.
- .install ⇒ Object
- .install_class_attribute ⇒ Object
- .install_execution_wrapper ⇒ Object
- .install_mattr_accessor ⇒ Object
-
.install_rails_load_hook ⇒ Object
Defer the Rails-module patch until Rails is defined.
-
.install_rails_module ⇒ Object
Patch the Rails module's class-level accessors (Rails.application, Rails.env, Rails.cache, etc.) to route through IsolatedExecutionState.
- .install_route_helpers_patch ⇒ Object
-
.install_rubygems ⇒ Object
Installed at
install(boot) time. - .install_shareable_constants ⇒ Object
- .install_url_helpers_patch ⇒ Object
- .install_zeitwerk_registry ⇒ Object
- .installed? ⇒ Boolean
-
.make_app_shareable!(app = Rails.application) ⇒ Object
Public API: make Rails.application shareable across Ractors.
-
.make_constant_shareable(const_path) ⇒ Object
Resolve a constant path string to a value, and if it exists and is not already shareable, replace it with its shareable (deep-frozen) version.
-
.patch_class_attribute! ⇒ Object
The actual patch.
- .patch_execution_wrapper! ⇒ Object
-
.patch_rails_module!(mod) ⇒ Object
The actual Rails-module patch.
- .patch_rubygems! ⇒ Object
- .patch_zeitwerk_registry! ⇒ Object
- .prepare_for_ractors! ⇒ Object
-
.shareable_constants ⇒ Object
--- Generic constant-sharing utilities (moved from rails_module.rb) ----- These are framework-agnostic; SHAREABLE_CONSTANTS lives here too, so the whole constant-shareability machinery is owned by core.rb.
-
.snapshot_gem_paths! ⇒ Object
Called from prepare_for_ractors! (post-boot, main Ractor) so the snapshot reflects Bundler-configured gem paths.
-
.snapshot_query_logs! ⇒ Object
Capture the QueryLogs handlers/formatter as a shareable snapshot for workers (called post-boot, main Ractor, in prepare_for_ractors!).
-
.split_const_path(path) ⇒ Object
Split "A::B::C" into [A::B (module), :C].
-
.thread_mode? ⇒ Boolean
Install all the patches.
-
.worker_app(frozen_app) ⇒ Object
Build the shareable Rack app handed to kino.
-
.worker_ar_init(app) ⇒ Object
Wrap
appso every worker Ractor initializes its ActiveRecord connections on first request.
Class Attribute Details
._abstract_registry ⇒ Object
Accessor for the abstract-controller registry (written by abstract! in main, read by abstract? in workers). Reassigned to a shareable frozen Hash at prepare_for_ractors! time.
110 111 112 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 110 def _abstract_registry @_abstract_registry end |
._view_context_fallback ⇒ Object
Returns the value of attribute _view_context_fallback.
112 113 114 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 112 def _view_context_fallback @_view_context_fallback end |
._view_context_registry ⇒ Object
Returns the value of attribute _view_context_registry.
111 112 113 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 111 def _view_context_registry @_view_context_registry end |
.thread_mode=(value) ⇒ Object (writeonly)
Sets the attribute thread_mode
153 154 155 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 153 def thread_mode=(value) @thread_mode = value end |
.version_policy ⇒ Object
Policy for version mismatches. One of :warn (default), :strict, :off.
Set before install:
RactorRailsShim.version_policy = :strict
Defaults to :warn when never explicitly set.
118 119 120 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 118 def version_policy @version_policy || :warn end |
Class Method Details
._build_shareable_fallback! ⇒ Object
Build the shareable fallback for every class_attribute / mattr_accessor value the shim has rerouted. For each registered attribute we:
1. Read the main-ractor value (from its IES slot, which `redefine`
seeded at class_attribute-definition time).
2. Make it shareable (deep-freeze + callable-replacement for any Procs
it holds — same technique as make_app_shareable!, applied to the
config sub-graph).
3. Store under the IES key in a frozen Hash on RactorRailsShim, which
is readable from every Ractor (it's a constant).
Workers' class_attribute readers fall back to this when their own IES slot is nil. Must run in the main Ractor. Idempotent.
204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 204 def _build_shareable_fallback! return if @fallback_built @fallback_built = true fallback = {} CLASS_ATTRIBUTES.each do |(owner_name, attr_name, ies_key, default_val)| # Skip the Rails logger — it's intrinsically unshareable (IO + Mutex + # mutable formatter) and workers build their own per-Ractor logger # via the patched reader. Trying to make it shareable would freeze the # IO, breaking logging in main too. next if owner_name == "Rails" && attr_name == :logger val = ActiveSupport::IsolatedExecutionState[ies_key] # For class_attribute values whose IES slot was never written but # whose definition-time DEFAULT was mutated in place during boot # (e.g. AbstractController::Base's `config`, whose default # ActiveSupport::OrderedOptions is filled with the real nested config # by railties), the live value lives in the main-Ractor # CLASS_ATTR_VALUES store, NOT in IES. Read it there so workers get # the real value instead of the empty definition-time default. if val.nil? && Ractor.main? val = RactorRailsShim::CLASS_ATTR_VALUES[ies_key] end # For mattr_accessor: the value may have been written to @@sym after # define-time (e.g. by an initializer). Read it from there if the IES # slot is nil (the seed only set the default; the live value may differ). if val.nil? && owner_name && attr_name.is_a?(Symbol) begin owner_mod = owner_name.split("::").inject(Object) { |ns, n| ns.const_get(n) } rescue nil if owner_mod && owner_mod.class_variable_defined?("@@#{attr_name}") val = owner_mod.class_variable_get("@@#{attr_name}") end rescue => e # ignore — best-effort read end end # For raw class ivars (PathRegistry, etc.): read @<attr_name> in main. if val.nil? && owner_name && attr_name.is_a?(Symbol) begin owner_mod = owner_name.split("::").inject(Object) { |ns, n| ns.const_get(n) } rescue nil if owner_mod && owner_mod.instance_variable_defined?("@#{attr_name}") val = owner_mod.instance_variable_get("@#{attr_name}") end rescue => e # ignore — best-effort read end end # For the Rails module accessors (owner_name == "Rails"): the value # may live in the @ivar (set by Rails' own writer via super, or # lazy-init'd by Rails' own reader) rather than in IES. Read it via # the actual accessor in main, which materializes the lazy-init value. if val.nil? && owner_name == "Rails" && defined?(::Rails) begin val = ::Rails.public_send(attr_name) if ::Rails.respond_to?(attr_name, false) rescue => e # ignore — best-effort read end end shareable_val = nil # Try the live value first. if !val.nil? shareable_val = _try_make_shareable(val, owner_name, attr_name) end # If the live value couldn't be shared (e.g. __callbacks holds # self-capturing Procs), fall back to the definition-time default. # For a frozen shared app this is correct: boot-time callbacks already # ran in main; workers treat them as already-run (empty/no-op). The # default is dup'd if it's a mutable container (Hash/Array) so each # entry in the fallback is an independent shareable copy. if shareable_val.nil? && !default_val.nil? shareable_val = _try_make_shareable(_shareable_copy(default_val), owner_name, attr_name, default: true) end fallback[ies_key] = shareable_val if shareable_val end fallback.freeze Ractor.make_shareable(fallback) # Make the shareable mattr-defaults subset shareable too (workers read # it via the constant). Frozen + reassigned via const_set. SHAREABLE_MATTR_DEFAULTS.freeze Ractor.make_shareable(SHAREABLE_MATTR_DEFAULTS) # Reassign the constants with the built (shareable) tables. const_set # warns "already initialized constant" — silence that one warning. verbose, $VERBOSE = $VERBOSE, nil begin const_set(:SHAREABLE_FALLBACK, fallback) const_set(:SHAREABLE_MATTR_DEFAULTS, SHAREABLE_MATTR_DEFAULTS) ensure $VERBOSE = verbose end fallback end |
._capture_ar_configurations! ⇒ Object
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 156 def _capture_ar_configurations! return if @_ar_configs_captured @_ar_configs_captured = true return unless defined?(::ActiveRecord::Base) begin # Build a plain Hash snapshot of every db config keyed by # [env_name][config_name] => config_hash. Use # ActiveRecord::Base.configurations.configs_for (returns DbConfig # objects with .name, .env_name, .configuration_hash) rather than # Rails.application.config.database_configuration (a legacy Hash whose # shape differs between single-config apps (flat Hash = the config # itself) and multi-config apps (nested Hash of name => config)). cfgs = ::ActiveRecord::Base.configurations snapshot = {} if cfgs.respond_to?(:configs_for) cfgs.configs_for.each do |dc| env_name = dc.env_name name = dc.name || "primary" hash = dc.configuration_hash next unless hash.is_a?(::Hash) snapshot[env_name] ||= {} snapshot[env_name][name] = hash.reject { |_k, v| v.nil? } end end # Fallback: legacy database_configuration Hash (env => { name => config } # OR env => flat config). Used if configs_for is unavailable. if snapshot.empty? raw = ::Rails.application.config.database_configuration rescue {} raw.each do |env_name, env_configs| next unless env_configs.is_a?(::Hash) snapshot[env_name] ||= {} if env_configs.key?("adapter") || env_configs.key?(:adapter) # Flat config: the env value IS the "primary" config itself. snapshot[env_name]["primary"] = env_configs.reject { |_k, v| v.nil? } else # Nested: env => { name => config } env_configs.each do |name, config| next unless config.is_a?(::Hash) snapshot[env_name][name] = config.reject { |_k, v| v.nil? } end end end end snapshot.freeze Ractor.make_shareable(snapshot) verbose, $VERBOSE = $VERBOSE, nil begin const_set(:AR_CONFIGURATIONS_SNAPSHOT, snapshot) ensure $VERBOSE = verbose end rescue => e # Best-effort; if we can't capture configs, workers won't be able # to auto-init connections. They can call init_worker_ar_connections! # manually with explicit configs. end end |
._check_version_support ⇒ Object
The shim's patches target specific Rails 8.1 class layouts and Ruby 4.0
Ractor semantics. On other versions, the patches may silently miss or
break things. Behavior on mismatch is governed by version_policy:
:warn (default) print a warning to $stderr, proceed anyway
:strict raise RactorRailsShim::UnsupportedVersionError
:off silent (for advanced users / experimentation)
Ruby mismatch always warns (Ractor semantics are not stable across majors); Rails mismatch uses the policy. This is real version detection (Gem::Version-based), not a string-prefix compare, so pre-release and patch versions sort correctly.
433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 433 def _check_version_support @version_policy ||= :warn unless RactorRailsShim::Version.supported_ruby? msg = "ractor-rails-shim: Ruby #{RUBY_VERSION} — shim developed " \ "against Ruby #{SUPPORTED_RUBY}. Ractor semantics may differ; " \ "the shim may break. Proceeding anyway." _version_mismatch(msg) end if RactorRailsShim::Version.rails && !RactorRailsShim::Version.supported_rails? rv = ::Rails::VERSION::STRING msg = "ractor-rails-shim: Rails #{rv} — shim developed against " \ "Rails #{RactorRailsShim::Version::TESTED_RAILS.join(", ")}. " \ "Class layouts (class_attribute, callbacks, PathRegistry, etc.) " \ "may differ; patches may miss blockers. Proceeding anyway. " \ "Set RactorRailsShim.version_policy = :strict to make this " \ "fatal; :off to silence." _version_mismatch(msg) end end |
._collect_controller_classes(app) ⇒ Object
830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 830 def _collect_controller_classes(app) classes = [] begin router = (app.respond_to?(:routes) ? app.routes : nil) || (defined?(::Rails) && ::Rails.application && ::Rails.application.routes) router.routes.each do |r| c = r.defaults[:controller] rescue nil next unless c && c.respond_to?(:camelize) klass = "#{c.camelize}Controller".safe_constantize rescue nil classes << klass if klass end rescue nil end begin if defined?(::ApplicationController) && ::ApplicationController.respond_to?(:descendants) classes.concat(::ApplicationController.descendants) end rescue nil end classes.compact.uniq end |
._collect_procs(app) ⇒ Object
558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 558 def _collect_procs(app) seen = {} procs = [] stack = [[app, "app", nil, nil]] until stack.empty? o, _path, parent, ivar = stack.pop next if o.equal?(nil) # Skip BasicObject subclasses that don't respond to is_a?/object_id # (e.g. ActiveSupport::Callbacks::CallTemplate internals). Must guard # BEFORE calling is_a? — BasicObject doesn't define it. next unless _introspectable?(o) if o.is_a?(Proc) procs << [o, _path, parent, ivar] next end next if seen[o.object_id] seen[o.object_id] = true next if o.is_a?(Mutex) || o.is_a?(Monitor) begin o.instance_variables.each do |iv| begin; v = o.instance_variable_get(iv); rescue; next; end stack << [v, "#{_path}.#{iv}", o, iv] if v end rescue => e # Some objects (BasicObject) don't support instance_variables end if o.is_a?(Array) o.each_with_index { |e, i| stack << [e, "#{_path}[#{i}]", o, nil] if e } elsif o.is_a?(Hash) o.each do |k, val| stack << [k, "#{_path}.key", o, nil] if k stack << [val, "#{_path}[#{k.inspect}]", o, nil] if val end dp = o.default_proc procs << [dp, "#{_path}.default_proc", o, :__default_proc__] if dp end end procs end |
._devise_mapping_replacement(proc_obj, _parent) ⇒ Object
Build a shareable replacement for a Devise scope constraint. The original Proc (devise/rails/routes.rb:363) does:
request.env["devise.mapping"] = Devise.mappings[scope]
true
The scope is captured in the Proc's binding. We call the original Proc once in main with a mock request to capture the mapping, then make it shareable and wrap it in a DeviseMappingCallable.
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 |
# File 'lib/ractor_rails_shim/patches/warden.rb', line 177 def _devise_mapping_replacement(proc_obj, _parent) mock_env = { "devise.mapping" => nil } mock_req = Struct.new(:env).new(mock_env) begin proc_obj.call(mock_req) rescue end mapping = mock_env["devise.mapping"] if mapping mapping = _devise_mapping_snapshot(mapping) end if mapping DeviseMappingCallable.new(mapping) else CallableConst.new(true) end end |
._find_files_server(mw) ⇒ Object
Find the Rack::Files (asset) server in the middleware chain, used by make_app_shareable! when replacing the Rack::Head#@app lambda (whose binding receiver is the Rack::Files instance). Moved here from make_shareable.rb so the Rack concern's pieces live together.
149 150 151 152 153 154 155 156 157 158 |
# File 'lib/ractor_rails_shim/patches/rack.rb', line 149 def _find_files_server(mw) cur = mw while cur if cur.class.name == "ActionDispatch::Static" return cur.instance_variable_get(:@file_server) end cur = cur.instance_variable_get(:@app) rescue nil end nil end |
._freeze_active_record_class_ivars! ⇒ Object
Freeze (make Ractor-shareable) every instance variable on every ActiveRecord model class in the MAIN Ractor, before the graph is frozen. Many AR model classes cache unshareable values in class-level ivars (@pending_attribute_ modifications, @column_defaults, @symbol_column_to_string_name_hash, @yaml_encoder, @dangerous_attribute_methods, ...). A worker Ractor cannot read an unshareable class-ivar value (Ractor::IsolationError) nor set one. Freezing them in main (where setting is allowed) yields shareable values that workers read without writing. AR Type objects freeze cleanly, so this is behavior-preserving; per-request code never mutates model class ivars.
609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 609 def _freeze_active_record_class_ivars! return unless defined?(::ActiveRecord::Base) models = [::ActiveRecord::Base] + (::ActiveRecord::Base.descendants rescue []) models.each do |klass| # NOTE: do NOT skip abstract classes (e.g. a primary_abstract_class # ApplicationRecord). Workers recurse into them via # apply_pending_attribute_modifications, so their class ivars must also # be shareable. klass.instance_variables.each do |ivar| val = klass.instance_variable_get(ivar) next if val.nil? || Ractor.shareable?(val) begin Ractor.make_shareable(val) rescue nil end end end end |
._freeze_class_ivars!(owner) ⇒ Object
Make every unshareable class ivar on owner shareable (deep-freeze) and
write it back. A class ivar holding a shareable value is readable from a
worker Ractor. Monitor/Mutex->NoOpLock; Concurrent::Map->frozen Hash;
values that can't be frozen (Procs, TypeMap) are left as-is.
521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 521 def _freeze_class_ivars!(owner) begin owner.instance_variables.each do |iv| v = owner.instance_variable_get(iv) rescue nil next unless v next if Ractor.shareable?(v) replacement = _shareable_ivar_replacement(v) next unless replacement begin owner.instance_variable_set(iv, replacement) rescue => e # frozen owner — leave as-is end end rescue => e end end |
._freeze_declared_callbacks! ⇒ Object
748 749 750 751 752 753 754 755 756 757 758 759 760 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 748 def _freeze_declared_callbacks! table = (@declared_callbacks || {}) # Deep-freeze (make shareable) so worker Ractors can read the constant. # Entries are Hashes of Symbols/booleans/nil/Arrays — all natively # shareable. A non-frozen constant raises Ractor::IsolationError when a # worker reads it. begin Ractor.make_shareable(table) RactorRailsShim.const_set(:SHAREABLE_DECLARED_CALLBACKS, table) rescue nil end end |
._freeze_global_class_ivars! ⇒ Object
Freeze (make Ractor-shareable) unshareable class-level ivars on GLOBAL classes (Time/Date timezone caches, I18n locale caches, ...) in the MAIN Ractor, before the graph is frozen. Unlike model classes, these are shared singletons whose class ivars (e.g. Time's @zone_default / @zone_cache) hold unshareable values that a worker Ractor would otherwise fail to read (Ractor::IsolationError). Freezing them in main yields shareable values.
635 636 637 638 639 640 641 642 643 644 645 646 647 648 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 635 def _freeze_global_class_ivars! classes = %w[Time Date DateTime I18n].filter_map { |n| Object.const_get(n) rescue nil } classes.each do |klass| klass.instance_variables.each do |ivar| val = klass.instance_variable_get(ivar) next if val.nil? || Ractor.shareable?(val) begin Ractor.make_shareable(val) rescue nil end end end end |
._freeze_global_constants! ⇒ Object
Replace GLOBAL constants that hold non-shareable values (e.g. Time/Date/DateTime::DATE_FORMATS contain Proc values) with frozen, shareable equivalents so worker Ractors can read them. Proc-valued format entries are dropped (to_fs falls back to to_s for those formats). This is done in the MAIN Ractor, where const_set is allowed.
655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 655 def _freeze_global_constants! constants = %w[Time Date DateTime].filter_map do |n| mod = Object.const_get(n) rescue nil mod.is_a?(Module) ? [mod, :DATE_FORMATS] : nil end constants.each do |mod, name| next unless mod.const_defined?(name, false) val = mod.const_get(name, false) next if Ractor.shareable?(val) shareable = if val.is_a?(Hash) h = {} val.each { |k, v| h[k] = v if Ractor.shareable?(v) } h.freeze elsif val.is_a?(Array) val.select { |v| Ractor.shareable?(v) }.freeze else val end begin mod.const_set(name, shareable) rescue nil end end end |
._freeze_journey_visitors! ⇒ Object
Journey's routing visitors are stored as instance singletons in class
constants (e.g. ActionDispatch::Journey::Visitors::Each::INSTANCE).
Worker Ractors read these constants while recognizing routes
(Node#each → Each::INSTANCE.accept, Path::Pattern#match →
offsets → node.each), and a non-frozen instance is NOT a shareable
object → Ractor::IsolationError: can not access non-shareable objects in constant ...::Each::INSTANCE by non-main Ractor. The visitor instances
are stateless, so freezing them makes them shareable with no behavior
change. The same applies to the DISPATCH_CACHE Hashes the visitor
accept/visit dispatch through. These constants are NOT reachable
from the frozen app graph (Ractor.make_shareable never touches them), so
we must freeze them explicitly here (in main, before workers spawn).
684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 684 def _freeze_journey_visitors! return unless defined?(::ActionDispatch::Journey::Visitors) v = ::ActionDispatch::Journey::Visitors [[:Each, :INSTANCE], [:String, :INSTANCE], [:Dot, :INSTANCE]].each do |klass, const| mod = v.const_get(klass) rescue nil next unless mod && mod.const_defined?(const) inst = mod.const_get(const) inst.freeze if inst.respond_to?(:freeze) && !inst.frozen? end [[:Visitor, :DISPATCH_CACHE], [:FunctionalVisitor, :DISPATCH_CACHE]].each do |klass, const| mod = v.const_get(klass) rescue nil next unless mod && mod.const_defined?(const) cache = mod.const_get(const) cache.freeze if cache.respond_to?(:freeze) && !cache.frozen? end # GTG::Builder::DUMMY_END_NODE is a non-shareable instance referenced when # a worker Ractor rebuilds the route simulator (e.g. if the warmed # @simulator cache is missing on the frozen graph). Make it Ractor-shareable # (deep-freeze) so workers can read the constant without # Ractor::IsolationError. It is a stateless dummy node, so this is # behavior-preserving. if defined?(::ActionDispatch::Journey::GTG::Builder) && ::ActionDispatch::Journey::GTG::Builder.const_defined?(:DUMMY_END_NODE) node = ::ActionDispatch::Journey::GTG::Builder.const_get(:DUMMY_END_NODE) Ractor.make_shareable(node) rescue nil end end |
._freeze_messages_constants! ⇒ Object
ActiveSupport::Messages::Metadata holds non-shareable Array constants
(ENVELOPE_SERIALIZERS / TIMESTAMP_SERIALIZERS) of serializer Modules, used
by MessageEncryptor during flash/session cookie serialization. A worker
Ractor reading these constants (e.g. on redirect_to, which encrypts a
flash message) raises Ractor::IsolationError. The Arrays are shareable once
frozen (their elements are Modules), so deep-freeze and const_set the
shareable copy back so workers read a shareable constant.
688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 688 def # Load ActiveSupport::MessagePack in the MAIN Ractor FIRST. metadata.rb # registers an `ActiveSupport.on_load(:message_pack)` callback that mutates # ENVELOPE_SERIALIZERS / TIMESTAMP_SERIALIZERS via `<<`. If that callback # first fires inside a worker Ractor (which happens the first time a # cookie's `detect_format` probes MessagePackWithFallback.dumped?, because # it lazily requires "active_support/message_pack"), it runs against the # already-frozen arrays below and raises # FrozenError: can't modify frozen Array # Loading here makes the callback fire once, in main, against the # non-frozen arrays; load hooks never fire again in workers. # Gem::LoadError is a ScriptError (not StandardError), so a bare # `rescue nil` on the require would NOT catch a missing msgpack gem. begin require "active_support/message_pack" rescue LoadError return end mod = (Object.const_get(:ActiveSupport) rescue nil)&.const_get(:Messages, false) rescue nil mod = mod&.const_get(:Metadata, false) rescue nil return unless mod.is_a?(Module) %i[ENVELOPE_SERIALIZERS TIMESTAMP_SERIALIZERS].each do |name| next unless mod.const_defined?(name, false) val = mod.const_get(name, false) next if Ractor.shareable?(val) shareable = Ractor.make_shareable(val) rescue val begin mod.const_set(name, shareable) rescue nil end end end |
._freeze_mime_negotiation! ⇒ Object
ActionDispatch::Http::MimeNegotiation holds module-level constants
(e.g. RESCUABLE_MIME_FORMAT_ERRORS, an Array of exception classes) that
are referenced from the request path (params_readable? -> rescue * RESCUABLE_MIME_FORMAT_ERRORS). These Arrays are non-frozen, hence
non-shareable, so a worker Ractor raises Ractor::IsolationError when it
reads them. Freeze the mutable constant-containing modules so workers can
read shareable copies. Regexp/Class constants are already shareable; only
the wrapping Array/Hash need freezing.
782 783 784 785 786 787 788 789 790 791 792 793 794 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 782 def _freeze_mime_negotiation! return unless defined?(::ActionDispatch::Http::MimeNegotiation) mod = ::ActionDispatch::Http::MimeNegotiation mod.constants.each do |name| c = mod.const_get(name) rescue nil next unless c.is_a?(::Array) || c.is_a?(::Hash) next if c.frozen? c.freeze ::Ractor.make_shareable(c) rescue nil end rescue => e warn "[ractor-rails-shim] _freeze_mime_negotiation!: #{e.class}: #{e.}" end |
._freeze_shareable_class_ivars! ⇒ Object
Capture each controller's OWN declared process_action symbol filters
(before_action / after_action) so worker Ractors can replay them.
WHY NOT READ __callbacks: Rails 8.1.3 under Ruby 4.0.5 (with Devise
5.0.4) has an eager-load class_attribute callback-chain leak — a parent
controller's __callbacks accumulates every subclass's filters (and
vice-versa), so ApplicationController.__callbacks[:process_action]
ends up carrying Devise's require_no_authentication AND
PostsController's set_post. Reading __callbacks therefore yields a
corrupted, unshareable chain. The app is genuinely broken in eager-load
(production) mode even without the shim.
Instead we intercept ActiveSupport::Callbacks.set_callback during
eager load (see _install_callback_declaration_capture!) and record, per
declaring controller class, the symbolic filters IT declares (kind,
filter, only/except). This captures the truth regardless of the leak.
The patched run_callbacks replays these per controller, walking
ancestors for inheritance.
We only capture SYMBOL filters (the common before_action :set_post
form). Proc/lambda filters are skipped — they are self-capturing and
cannot be replayed safely in a worker (known limitation; symbolic
filters cover the typical case, including Devise's :authenticate_user!
/ :require_no_authentication). Stored in
RactorRailsShim::SHAREABLE_DECLARED_CALLBACKS keyed by the controller
class object_id (stable across Ractors since classes are shared).
Make Ractor-shareable the class instance variables listed in
SHAREABLE_CLASS_IVARS (e.g. ActiveSupport::Editor.@editors,
Warden::Strategies.@strategies). Worker Ractors read these during request
dispatch; an unshareable value raises Ractor::IsolationError. We deep-freeze
the value and write it back so workers read the shareable copy. Also
pre-touch any memoizing accessor so workers don't try to write the ivar
lazily (which would raise FrozenError on the frozen class).
721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 721 def _freeze_shareable_class_ivars! SHAREABLE_CLASS_IVARS.each do |(class_name, ivar)| mod = class_name.split("::").inject(Object) { |ns, n| ns.const_get(n) } rescue nil next unless mod && mod.instance_variable_defined?(ivar) val = mod.instance_variable_get(ivar) next if val.nil? begin Ractor.make_shareable(val) mod.instance_variable_set(ivar, val) rescue nil rescue nil end end # Pre-touch memoizing accessors so workers short-circuit instead of # writing the (now frozen) ivar on first read. begin ::ActiveSupport::Editor.current if defined?(::ActiveSupport::Editor) rescue nil end begin ::Warden::Strategies._strategies if defined?(::Warden::Strategies) rescue nil end end |
._generate_ar_attribute_methods! ⇒ Object
Force AR attribute-method generation for every loaded model in the MAIN Ractor. See the call site in make_app_shareable! for why; without this a worker Ractor dies with Ractor::IsolationError on the first model instantiation (GeneratedAttributeMethods::LOCK is a non-shareable Monitor).
496 497 498 499 500 501 502 503 504 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 496 def _generate_ar_attribute_methods! return unless defined?(::ActiveRecord::Base) ::ActiveRecord::Base.descendants.each do |klass| next unless klass.respond_to?(:define_attribute_methods) klass.define_attribute_methods rescue StandardError nil end end |
._install_abstract_controller_patch ⇒ Object
Patch AbstractController::Base.controller_path to not write/read the action_methods, clear_action_methods!, abstract!, abstract?, and _prefixes to route through IES or use the shareable fallback.
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 |
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 49 def _install_abstract_controller_patch return if @abstract_controller_patched @abstract_controller_patched = true _register_patch :abstract_controller, "8.1" return unless defined?(::AbstractController::Base) ac = ::AbstractController::Base # Populate the shareable abstract registry from every loaded controller # class's @abstract ivar (set by abstract! / inherited at boot). Workers # read this via the patched abstract? (per-class values can't live in # per-Ractor IES). registry = {} ac.descendants.each do |klass| begin registry[klass] = klass.instance_variable_get(:@abstract) if klass.instance_variable_defined?(:@abstract) rescue => e # ignore — best-effort end end registry[ac] = ac.instance_variable_get(:@abstract) if ac.instance_variable_defined?(:@abstract) registry.freeze Ractor.make_shareable(registry) self._abstract_registry = registry ac.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def controller_path cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_controller_path_cache] ||= {}) v = cache[self] return v if v if Ractor.main? && instance_variable_defined?(:@controller_path) v = @controller_path cache[self] = v return v end computed = anonymous? ? nil : name.delete_suffix("Controller").underscore cache[self] = computed computed end # action_methods: `@action_methods ||= public_instance_methods(true) - # internal_methods).map(&:name).to_set` — raw class-ivar lazy init. # The value is a Set of Symbols (shareable once frozen). Route through # IES; workers compute it from public_instance_methods (no ivar read) # and cache in their own slot. Read per-request during dispatch. def action_methods cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_action_methods_cache] ||= {}) v = cache[self] return v if v if Ractor.main? && instance_variable_defined?(:@action_methods) v = @action_methods cache[self] = v return v end methods = public_instance_methods(true) - internal_methods methods.map!(&:name) computed = methods.to_set cache[self] = computed computed end def clear_action_methods! if Ractor.main? @action_methods = nil end ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_action_methods_cache] = nil end # abstract! / abstract / abstract? — raw class ivar (@abstract), a # per-CLASS boolean. IES is per-Ractor (single value), so we can't use a # single IES key for all classes. Instead use a shareable registry # (Hash class→bool) built at prepare_for_ractors! time. Workers read # the registry; main reads its live @abstract ivar (set by abstract! # / inherited). `internal_methods` loops on abstract?. def abstract! RactorRailsShim._abstract_registry[self] = true if Ractor.main? @abstract = true if Ractor.main? end def abstract if Ractor.main? && instance_variable_defined?(:@abstract) @abstract else (RactorRailsShim._abstract_registry || RactorRailsShim::ABSTRACT_REGISTRY)[self] || false end end alias_method :abstract?, :abstract RUBY # Patch ActionView::ViewPaths::ClassMethods#_prefixes (overrides any # Base version). Original: `@_prefixes ||= begin; return local_prefixes # if superclass.abstract?; local_prefixes + superclass._prefixes; end`. # @_prefixes is a per-CLASS class ivar (workers can't read). Recurse # using the patched abstract? and cache in a per-Ractor Hash by class. if defined?(::ActionView::ViewPaths::ClassMethods) vp = ::ActionView::ViewPaths::ClassMethods vp.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def _prefixes cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_vp_prefixes_cache] ||= {}) v = cache[self] return v if v if Ractor.main? && instance_variable_defined?(:@_prefixes) v = @_prefixes cache[self] = v return v end computed = if superclass.respond_to?(:abstract?) && superclass.abstract? local_prefixes elsif superclass.respond_to?(:_prefixes) local_prefixes + superclass._prefixes else local_prefixes end cache[self] = computed computed end RUBY end # AbstractController::UrlFor::ClassMethods#action_methods ALSO has a # `@action_methods ||= ...` lazy init (it overrides Base.action_methods # to subtract route helper names). Patch it the same way. if defined?(::AbstractController::UrlFor::ClassMethods) url_for_cm = ::AbstractController::UrlFor::ClassMethods url_for_cm.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def action_methods cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_url_for_action_methods_cache] ||= {}) v = cache[self] return v if v if Ractor.main? && instance_variable_defined?(:@action_methods) v = @action_methods cache[self] = v return v end # NOTE: the original reads `@action_methods ||= if _routes; super - # _routes.named_routes.helper_names; else; super; end`. But # `_routes` is a singleton method defined via `define_method` with # a block (route_set.rb:610), capturing the defining Ractor's # binding → "defined with an un-shareable Proc in a different # Ractor" when called from a worker. Instead, read the route set # directly from the shareable Rails.application (frozen, shared). base = super routes = Ractor.main? ? (respond_to?(:_routes) ? _routes : nil) : (defined?(::Rails) && ::Rails.application ? ::Rails.application.routes : nil) computed = if routes base - routes.named_routes.helper_names else base end cache[self] = computed computed end RUBY end end |
._install_action_controller_controller_name_patch ⇒ Object
Patch ActionController::Metal.controller_name (a class method). It
memoizes its computed String in a lazy class ivar (@controller_name ||=),
which a worker Ractor cannot write. Route the cache through
IsolatedExecutionState keyed by the class name so each Ractor builds its
own copy; the computation is deterministic from the class name.
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 |
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 207 def _install_action_controller_controller_name_patch return if @action_controller_controller_name_patched @action_controller_controller_name_patched = true _register_patch :action_controller_controller_name, "8.1" return unless defined?(::ActionController::Metal) ::ActionController::Metal.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def controller_name key = :"ractor_rails_shim_controller_name_\#{name}" v = ActiveSupport::IsolatedExecutionState[key] return v if v cn = (name.demodulize.delete_suffix("Controller").underscore unless anonymous?) ActiveSupport::IsolatedExecutionState[key] = cn cn end RUBY end |
._install_action_dispatch_http_url_patch ⇒ Object
ActionDispatch::Http::URL reads the tld_length class variable
DIRECTLY (@@tld_length) in normalize_host and in the default-parameter
of domain/subdomains/subdomain. Class variables are not readable from
a non-main Ractor, so a worker raises
"Ractor::IsolationError: can not access class variables ... @@tld_length".
The shim routes the mattr_accessor :tld_length READER through IES, but the
literal @@tld_length references bypass that reader. Replace them with the
accessor method (which the shim's mattr_accessor rewrite makes
worker-safe). domain/subdomains/subdomain live in the Url module
mixed into ActionDispatch::Request, so patch that module too.
806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 806 def _install_action_dispatch_http_url_patch return if @action_dispatch_http_url_patched @action_dispatch_http_url_patched = true _register_patch :action_dispatch_http_url, "8.1" return unless defined?(::ActionDispatch::Http::URL) url = ::ActionDispatch::Http::URL # normalize_host is a module_function: build_host_url calls the # MODULE-LEVEL copy, so redefining the instance method alone leaves the # original (@@tld_length-reading) one in place. Patch the singleton # (module-level) method instead. url.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def normalize_host(_host, options) return _host unless named_host?(_host) tld_length = options[:tld_length] || tld_length() subdomain = options.fetch :subdomain, true domain = options[:domain] host = +"" if subdomain == true return _host if domain.nil? host << extract_subdomains_from(_host, tld_length).join(".") elsif subdomain host << subdomain.to_param end host << "." unless host.empty? host << (domain || extract_domain_from(_host, tld_length)) host end RUBY if defined?(::ActionDispatch::Http::URL::Url) ::ActionDispatch::Http::URL::Url.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def domain(tld_length = tld_length()) ActionDispatch::Http::URL.extract_domain(host, tld_length) end def subdomains(tld_length = tld_length()) ActionDispatch::Http::URL.extract_subdomains(host, tld_length) end def subdomain(tld_length = tld_length()) ActionDispatch::Http::URL.extract_subdomain(host, tld_length) end RUBY end end |
._install_action_dispatch_mounted_helpers_patch ⇒ Object
Patch ActionDispatch::Routing::RouteSet::MountedHelpers#main_app (and
its _main_app worker). main_app is define_method-ed at boot capturing
the MAIN ractor's RouteSet + url_helpers in its block binding, so
calling it from a worker Ractor raises "defined with an un-shareable
Proc in a different Ractor". Devise's _devise_route_context calls
send(:main_app) to get the route context for its url helpers. Redefine
via string eval, building the RoutesProxy from the shareable RouteSet
(RactorRailsShim::SHAREABLE_ROUTES) so workers get a valid context.
183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 183 def _install_action_dispatch_mounted_helpers_patch return if @mounted_helpers_patched @mounted_helpers_patched = true _register_patch :mounted_helpers, "8.1" return unless defined?(::ActionDispatch::Routing::RouteSet::MountedHelpers) mh = ::ActionDispatch::Routing::RouteSet::MountedHelpers return unless mh.method_defined?(:main_app) mh.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def _main_app ::ActionDispatch::Routing::RoutesProxy.new( RactorRailsShim::SHAREABLE_ROUTES, _routes_context, RactorRailsShim::SHAREABLE_ROUTES.url_helpers, nil ) end def main_app @_main_app ||= _main_app end RUBY end |
._install_action_dispatch_routing_patch ⇒ Object
Patch ActionDispatch::Routing::RouteSet URL generation. The named route
helpers (post_path, session_path, ...) are generated at boot in the
main Ractor by RouteSet#add, which captures the PATH / UNKNOWN
lambda constants (route_set.rb:349-350) into each helper's url_strategy
ivar. Those lambdas were defined in the main Ractor, so calling them from
a worker Ractor raises RuntimeError: defined with an un-shareable Proc in a different Ractor. Replace them with shareable Callable objects (Plain
old objects with a #call method, made shareable via
Ractor.make_shareable) that delegate to ActionDispatch::Http::URL
(module methods, callable from any Ractor).
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 215 def _install_action_dispatch_routing_patch return if @action_dispatch_routing_patched @action_dispatch_routing_patched = true _register_patch :action_dispatch_routing, "8.1" return unless defined?(::ActionDispatch::Routing::RouteSet) return unless defined?(::ActionDispatch::Http::URL) # Shareable Callable replacements for the PATH / UNKNOWN lambda constants. unless RactorRailsShim.const_defined?(:AVPathStrategy) RactorRailsShim.const_set(:AVPathStrategy, Ractor.make_shareable(Object.new.tap do |o| def o.call() ActionDispatch::Http::URL.path_for() end end)) end unless RactorRailsShim.const_defined?(:AVUnknownStrategy) RactorRailsShim.const_set(:AVUnknownStrategy, Ractor.make_shareable(Object.new.tap do |o| def o.call() ActionDispatch::Http::URL.url_for() end end)) end rs = ::ActionDispatch::Routing::RouteSet # `RouteSet::PATH` / `RouteSet::UNKNOWN` (route_set.rb:349-350) are lambdas # defined in the main Ractor. They are referenced as default parameter # values (`def url_for(..., url_strategy = UNKNOWN, ...)`) and inside # `path_for`/`define_url_helper`. Reading those constants from a worker # Ractor raises `Ractor::IsolationError: can not access non-shareable # objects in constant ...UNKNOWN`. Replace them with the shareable # Callable objects (which perform the identical `ActionDispatch::Http::URL` # lookups) so workers read a shareable constant instead of an unshareable # lambda. Behaviour is unchanged in main (same `#call(options)` contract). unless rs.const_defined?(:PATH) && Ractor.shareable?(rs.const_get(:PATH)) verbose = $VERBOSE $VERBOSE = nil rs.const_set(:PATH, RactorRailsShim::AVPathStrategy) rs.const_set(:UNKNOWN, RactorRailsShim::AVUnknownStrategy) $VERBOSE = verbose if defined?(verbose) end # `ActionDispatch::Journey::Router::Utils::ENCODER` (`UriEncoder.new`) and # its sibling constants (`DEC2HEX`, `EMPTY`, `US_ASCII`, the unreserved/ # segment regexes, ...) are referenced by `escape_path`/`escape_segment`, # which the journey URL formatter invokes while building a path in a worker # Ractor. An unfrozen object/array/string held in a constant is unshareable, # so workers reading it raise IsolationError. Freeze each constant in place # (via `Ractor.make_shareable`) so the shareable-frozen values are readable # from any Ractor. if defined?(::ActionDispatch::Journey::Router::Utils) utu = ::ActionDispatch::Journey::Router::Utils utu.constants.each do |c| begin v = utu.const_get(c) Ractor.make_shareable(v) if v && !Ractor.shareable?(v) rescue nil end end end # `RouteSet::RESERVED_OPTIONS` (route_set.rb:838) is a mutable Array used # as a default parameter value in `url_for`/`path_for`. A non-frozen Array # is unshareable, so workers reading the constant raise IsolationError. # Freeze it in place so the constant becomes shareable. begin Ractor.make_shareable(rs.const_get(:RESERVED_OPTIONS)) rescue nil end # Warm the lazy (memoized) caches on every Journey route and its # Path::Pattern BEFORE `make_app_shareable!` deep-freezes them. Several of # these caches are filled with `||=` (e.g. `Route#parts`, # `Route#required_parts`, `Route#required_defaults`, # `Path::Pattern#requirements_for_missing_keys_check`, `#to_regexp`, # `#offsets`, `#required_names`, `#optional_names`). They are computed # deterministically, but assigning the memoized ivar on a frozen object # from a worker Ractor raises FrozenError. Computing them here (in main, # while the objects are still mutable) populates the ivars so the frozen, # shared copies already hold the values and workers only read them. if Ractor.main? && defined?(::Rails) && ::Rails.application begin rset = ::Rails.application.routes all = [] all.concat(rset.named_routes.send(:routes).values) rescue nil all.concat(rset.set.routes) rescue nil all.uniq.each do |route| next unless route.respond_to?(:path) route.parts rescue nil route.required_parts rescue nil route.required_defaults rescue nil p = route.path p.requirements_for_missing_keys_check rescue nil p.to_regexp rescue nil p.offsets rescue nil p.required_names rescue nil p.optional_names rescue nil end rescue nil end end # Capture the (shareable) RouteSet so workers can build URLs without # calling `#_routes` — which is `define_method(:_routes) { @_routes || # routes }` (route_set.rb:612), a block capturing the main Ractor's # `routes` reference. Calling that block from a worker raises # "defined with an un-shareable Proc in a different Ractor". We stash the # RouteSet as a shareable constant and point `_routes` at it. if Ractor.main? begin routes = Rails.application.routes if defined?(::Rails) && ::Rails.application unless routes.nil? verbose = $VERBOSE $VERBOSE = nil RactorRailsShim.const_set(:SHAREABLE_ROUTES, routes) unless RactorRailsShim.const_defined?(:SHAREABLE_ROUTES) end rescue nil ensure $VERBOSE = verbose if defined?(verbose) end end # RouteSet#url_for receives url_strategy (the captured PATH/UNKNOWN # lambda) and calls `url_strategy.call options` internally. Coerce a # non-shareable strategy to the shareable Callable before delegating. unless rs.method_defined?(:url_for_without_shim) rs.alias_method(:url_for_without_shim, :url_for) end rs.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def url_for(options, route_name = nil, url_strategy = UNKNOWN, method_name = nil, reserved = RESERVED_OPTIONS) url_strategy = RactorRailsShim::AVUnknownStrategy unless Ractor.shareable?(url_strategy) url_for_without_shim(options, route_name, url_strategy, method_name, reserved) end RUBY # OptimizedUrlHelper#call invokes `url_strategy.call options` DIRECTLY # (route_set.rb:228) without going through url_for, so the coercion above # doesn't cover it. Redefine it to call the shareable strategy Callable # (the one passed in by our redefined helper methods), replicating the # original body exactly otherwise. ::ActionDispatch::Routing::RouteSet::NamedRouteCollection::UrlHelper::OptimizedUrlHelper.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def call(t, method_name, args, inner_options, url_strategy) if args.size == arg_size && !inner_options && optimize_routes_generation?(t) options = t.url_options.merge @options path = optimized_helper(args) path << "/" if options[:trailing_slash] && !path.end_with?("/") options[:path] = path original_script_name = options.delete(:original_script_name) script_name = t._routes.find_script_name(options) if original_script_name script_name = original_script_name + script_name end options[:script_name] = script_name strat = Ractor.shareable?(url_strategy) ? url_strategy : RactorRailsShim::AVPathStrategy strat.call(options) else super end end RUBY # The base (non-optimized) `UrlHelper#call` (route_set.rb:278) is hit # whenever a helper is generated as a plain `UrlHelper` (e.g. our re-run # loop) or when optimization is skipped. The original reads `t.url_options` # and `t._routes`, both of which assume `t` is a controller/view context # whose `_routes`/`url_options` are reachable from a worker. In practice # `t` may be the `NamedRouteCollection` (helpers proxy) or any object that # lacks these. Route both through the shareable RouteSet / url-options # snapshot, falling back to `t`'s own accessors only when it actually # provides them (real controller/view). This makes path generation # (host-independent) work from any Ractor regardless of `t`. ::ActionDispatch::Routing::RouteSet::NamedRouteCollection::UrlHelper.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def call(t, method_name, args, inner_options, url_strategy) begin controller_options = t.url_options rescue controller_options = RactorRailsShim::URL_OPTIONS_DEFAULTS || {} end options = controller_options.merge @options hash = handle_positional_args(controller_options, inner_options || {}, args, options, @segment_keys) begin routes = t._routes rescue routes = RactorRailsShim::SHAREABLE_ROUTES end routes.url_for(hash, route_name, url_strategy, method_name) end RUBY # Named route helpers (`post_path`, `post_url`, ...) are generated by # `NamedRouteCollection#define_url_helper` (route_set.rb:333) via # `mod.define_method(name) { |*args| ... helper.call(...) }` — a BLOCK that # captures the `helper` object (an OptimizedUrlHelper holding the route) # and the `url_strategy` lambda (PATH/UNKNOWN, both defined in main). # Calling that block from a worker Ractor raises # "defined with an un-shareable Proc in a different Ractor" before any # code runs. Patch `define_url_helper` to (a) make the helper shareable # via `Ractor.make_shareable` (deep-freeze; routes are read-only after # boot) and stash it in a shareable Hash keyed by name, and (b) define the # method with a STRING (no captured binding) that references the Hash and # the shareable strategy Callable directly. unless RactorRailsShim.const_defined?(:URL_HELPERS) RactorRailsShim.const_set(:URL_HELPERS, {}) end nrc = ::ActionDispatch::Routing::RouteSet::NamedRouteCollection unless nrc.method_defined?(:define_url_helper_without_shim) nrc.alias_method(:define_url_helper_without_shim, :define_url_helper) end nrc.define_method(:define_url_helper) do |mod, name, helper, url_strategy| begin # Detach the helper from the live route object before deep-freezing # it for cross-Ractor sharing. The non-optimized UrlHelper#call only # needs @options / @segment_keys / @route_name to build the options # hash and then delegates to `t._routes.url_for(route_name, ...)`, # which looks the route up in the (shareable) RouteSet by name. The # @route reference would pull the whole route graph into the freeze, # freezing objects that make_app_shareable! must still be able to # mutate (e.g. Devise route constraints) -> FrozenError. if helper.respond_to?(:instance_variable_get) helper.instance_variable_set(:@route, nil) rescue nil opts = helper.instance_variable_get(:@options) helper.instance_variable_set(:@options, opts.dup.freeze) rescue nil segs = helper.instance_variable_get(:@segment_keys) helper.instance_variable_set(:@segment_keys, segs.dup.freeze) rescue nil end helper = Ractor.make_shareable(helper) rescue nil end RactorRailsShim::URL_HELPERS[name] = helper strategy_const = url_strategy.equal?(::ActionDispatch::Routing::RouteSet::PATH) ? "AVPathStrategy" : "AVUnknownStrategy" mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{name}(*args) last = args.last options = \\ case last when ::Hash args.pop when ::ActionController::Parameters args.pop.to_h end RactorRailsShim::URL_HELPERS[#{name.inspect}].call( self, #{name.inspect}, args, options, RactorRailsShim::#{strategy_const}) end RUBY end # Re-run the (now patched) helper generation for every route already # drawn at boot, so the helpers the app actually uses are worker-safe. # Routes are drawn during `Rails.application.initialize!`, which runs # before `prepare_for_ractors!`, so the originals are still block-based. if Ractor.main? && defined?(::Rails) && ::Rails.application begin named = ::Rails.application.routes.named_routes path_mod = named.instance_variable_get(:@path_helpers_module) url_mod = named.instance_variable_get(:@url_helpers_module) named.send(:routes).each do |route_name, route| # Build the helper directly via `UrlHelper.new` (NOT `UrlHelper.create`, # which calls `optimize_helper?` -> `route.glob?` -> `route.path.ast.glob?` # and `route.path.ast` is nil by the time routes are finalized post-boot). helper = ::ActionDispatch::Routing::RouteSet::NamedRouteCollection::UrlHelper.new( route, route.defaults, route_name) named.define_url_helper(path_mod, :"#{route_name}_path", helper, ::ActionDispatch::Routing::RouteSet::PATH) if path_mod named.define_url_helper(url_mod, :"#{route_name}_url", helper, ::ActionDispatch::Routing::RouteSet::UNKNOWN) if url_mod end verbose = $VERBOSE $VERBOSE = nil RactorRailsShim.const_set(:URL_HELPERS, Ractor.make_shareable(RactorRailsShim::URL_HELPERS)) unless Ractor.shareable?(RactorRailsShim::URL_HELPERS) rescue nil ensure $VERBOSE = verbose if defined?(verbose) end end # `ActionController::UrlFor#url_options` (action_controller/metal/url_for.rb:45) # builds its option hash from `request.host` / `request.optional_port` / # `request.protocol` / `request.path_parameters` and merges in # `default_url_options`. The controller instance rendered in a worker Ractor # has a `request` built from the shared Rack env, but the values it returns # (and the `default_url_options` class value, an unshareable Hash stored as a # class ivar on ActionController::Base) cannot be read/called from a worker # without raising Ractor isolation errors. For path-only helpers (the common # case in views) the host/port/protocol are irrelevant, and `default_url_options` # is the same deterministic value everywhere, so capture it once in main as a # shareable snapshot and have workers use it directly, skipping the # request-derived portion. unless RactorRailsShim.const_defined?(:URL_OPTIONS_DEFAULTS) begin if Ractor.main? && defined?(::ActionController::Base) defaults = ::ActionController::Base. defaults = defaults.dup.freeze if defaults.respond_to?(:freeze) RactorRailsShim.const_set(:URL_OPTIONS_DEFAULTS, Ractor.make_shareable(defaults)) end rescue nil end end if defined?(::ActionController::UrlFor) ::ActionController::UrlFor.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def url_options return super if Ractor.main? @_url_options ||= begin opts = (RactorRailsShim::URL_OPTIONS_DEFAULTS || {}).dup begin req = request if respond_to?(:request) if req opts[:host] = req.host if opts[:host].nil? && req.respond_to?(:host) opts[:protocol] = req.protocol if opts[:protocol].nil? && req.respond_to?(:protocol) opts[:port] = req.port if opts[:port].nil? && req.respond_to?(:port) opts[:_recall] = req.path_parameters if req.respond_to?(:path_parameters) end rescue nil end opts.freeze end end RUBY end # NOTE: the block-based `_routes` accessors that break workers are now # fixed at their source by `_install_url_helpers_patch` (patches/ # url_helpers.rb), which intercepts `Module#redefine_singleton_method` # / `Module#define_method` for `:_routes` and replaces the main-Ractor # block with a string-eval'd method returning `Rails.application.routes`. # No per-class enumeration needed. end |
._install_action_view_field_type_patch ⇒ Object
Patch ActionView::Helpers::Tags::TextField.field_type (and the subclasses
EmailField/PasswordField/... that inherit it). The original memoizes its
computed String in a lazy class ivar (@field_type ||= name...). The
class ivar is per-subclass and unshareable-writable from a worker Ractor.
Route the cache through IsolatedExecutionState keyed by the class name so
each Ractor builds its own copy; the computation is deterministic.
544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 544 def _install_action_view_field_type_patch return if @action_view_field_type_patched @action_view_field_type_patched = true _register_patch :action_view_field_type, "8.1" return unless defined?(::ActionView::Helpers::Tags::TextField) tf = ::ActionView::Helpers::Tags::TextField tf.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def field_type key = :"ractor_rails_shim_field_type_\#{name}" v = ActiveSupport::IsolatedExecutionState[key] return v if v ft = name.split("::").last.sub("Field", "").downcase ActiveSupport::IsolatedExecutionState[key] = ft ft end RUBY end |
._install_action_view_partial_path_patch ⇒ Object
Patch ActionView::AbstractRenderer::ObjectRendering#partial_path. The
original reads PREFIXED_PARTIAL_NAMES — a Concurrent::Map constant
(nested Concurrent::Maps) — and writes a nested entry via
PREFIXED_PARTIAL_NAMES[@context_prefix][path] ||= .... Concurrent::Map
is intrinsically unshareable (it refuses #freeze), so a worker Ractor
cannot read the constant NOR write to it. Redefine the method to use a
per-Ractor Hash via IsolatedExecutionState (each Ractor builds its own
cache from merge_prefix_into_object_path, which is deterministic).
514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 514 def _install_action_view_partial_path_patch return if @action_view_partial_path_patched @action_view_partial_path_patched = true _register_patch :action_view_partial_path, "8.1" return unless defined?(::ActionView::AbstractRenderer) ::ActionView::AbstractRenderer::ObjectRendering.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def partial_path(object, view) object = object.to_model if object.respond_to?(:to_model) path = if object.respond_to?(:to_partial_path) object.to_partial_path else raise ArgumentError.new("\#{object.inspect}' is not an ActiveModel-compatible object. It must implement #to_partial_path.") end if view.prefix_partial_path_with_controller_namespace cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_prefixed_partial_names] ||= {}) cache[@context_prefix] ||= {} cache[@context_prefix][path] ||= merge_prefix_into_object_path(@context_prefix, path.dup) else path end end RUBY end |
._install_action_view_resolver_patch ⇒ Object
Patch ActionView::FileSystemResolver#_find_all. The original reads the
resolver's @unbound_templates cache, which make_app_shareable!
rewrites from a Concurrent::Map into a frozen Hash (Concurrent::Map
refuses #freeze). The original then calls cache.compute_if_absent
(a Concurrent::Map API) on it, which a frozen Hash lacks -> NoMethodError
in a worker Ractor. Route the per-virtual-path cache through
IsolatedExecutionState instead: each Ractor builds its own mutable Hash
(deterministic from disk via unbound_templates_from_path), so the
frozen shareable app graph is never mutated.
441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 441 def _install_action_view_resolver_patch return if @action_view_resolver_patched @action_view_resolver_patched = true _register_patch :action_view_resolver, "8.1" return unless defined?(::ActionView::FileSystemResolver) # Eager-load nested constants referenced below (workers can't autoload). if Ractor.main? ::ActionView::TemplateDetails rescue nil ::ActionView::TemplatePath rescue nil end ::ActionView::FileSystemResolver.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def _find_all(name, prefix, partial, details, key, locals) requested_details = key || ::ActionView::TemplateDetails::Requested.new(**details) virtual = ::ActionView::TemplatePath.virtual(name, prefix, partial) # Key the cache by resolver path AND virtual path: each resolver # (app/views, each gem) has its own @path and its own templates. # Keying only by virtual path would let the first resolver poison # the cache for all others (e.g. app/views caches [] for # devise/sessions/new, hiding the template that lives in the # devise gem resolver). cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_resolver_cache] ||= {}) cache_key = [@path, virtual] unbound_templates = if cache.key?(cache_key) cache[cache_key] else path = ::ActionView::TemplatePath.build(name, prefix, partial) tmpls = unbound_templates_from_path(path) cache[cache_key] = tmpls tmpls end filter_and_sort_by_details(unbound_templates, requested_details).map do |unbound_template| unbound_template.bind_locals(locals) end end RUBY # Patch ActionView::Resolver::PathParser#parse. The resolver's # @path_parser instance is part of the shareable app graph frozen by # make_app_shareable!, and the original method memoizes its compiled # regex in `@regex ||= build_path_regex` — assigning @regex on a frozen # object raises FrozenError in a worker Ractor. Route the memoization # through IsolatedExecutionState keyed by the parser's object_id, so each # Ractor compiles its own regex once without mutating the frozen object. if defined?(::ActionView::Resolver::PathParser) pp = ::ActionView::Resolver::PathParser pp_key = :ractor_rails_shim_path_parser_regex pp_key_str = pp_key.inspect pp.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def parse(path) regex = ActiveSupport::IsolatedExecutionState[:"#{pp_key_str}_\#{object_id}"] ||= build_path_regex match = regex.match(path) path = ::ActionView::TemplatePath.build(match[:action], match[:prefix] || "", !!match[:partial]) details = ::ActionView::TemplateDetails.new( match[:locale]&.to_sym, match[:handler]&.to_sym, match[:format]&.to_sym, match[:variant]&.to_sym ) ::ActionView::Resolver::PathParser::ParsedPath.new(path, details) end RUBY end end |
._install_action_view_safe_join_patch ⇒ Object
Patch ActionView::Helpers::OutputSafetyHelper#safe_join. Its default
separator parameter is sep = $, — a reference to the $ global, which a
worker Ractor cannot read (Ractor::IsolationError: can not access global
variable $,). The $ global is nil in every normal Rails process, so
defaulting to nil reproduces the identical behaviour without touching the
global.
568 569 570 571 572 573 574 575 576 577 578 579 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 568 def _install_action_view_safe_join_patch return if @action_view_safe_join_patched @action_view_safe_join_patched = true _register_patch :action_view_safe_join, "8.1" return unless defined?(::ActionView::Helpers::OutputSafetyHelper) ::ActionView::Helpers::OutputSafetyHelper.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def safe_join(array, sep = nil) sep = ERB::Util.unwrapped_html_escape(sep) array.flatten.map! { |i| ERB::Util.unwrapped_html_escape(i) }.join(sep).html_safe end RUBY end |
._install_active_model_attribute_patch ⇒ Object
See patches/active_model_attribute.rb. When the frozen :ractor graph is
built, each model class's _default_attributes template (and the
FromDatabase instances within it) is deep-frozen. Attribute#dup_or_share
returns self for immutable column types, so a worker's NEW record would
share a frozen Attribute and raise FrozenError on first read/write. This
patch makes a frozen receiver yield a fresh, mutable Attribute so writes
(POST/create) work in workers. No-op in normal (unfrozen) Rails.
547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 547 def _install_active_model_attribute_patch return @am_attribute_patched if defined?(@am_attribute_patched) && @am_attribute_patched @am_attribute_patched = true return unless defined?(::ActiveModel::Attribute) ::ActiveModel::Attribute.include(::RactorRailsShim::ActiveModelAttributePatch) if defined?(::ActiveModel::AttributeRegistration) && ::ActiveModel::AttributeRegistration.const_defined?(:ClassMethods) ::ActiveModel::AttributeRegistration::ClassMethods.prepend( ::RactorRailsShim::ActiveModelAttributeRegistrationPatch ) end if defined?(::ActiveRecord::Attributes) && ::ActiveRecord::Attributes.const_defined?(:ClassMethods) ::ActiveRecord::Attributes::ClassMethods.prepend( ::RactorRailsShim::ActiveRecordAttributesPatch ) end if defined?(::ActiveRecord::ModelSchema) && ::ActiveRecord::ModelSchema.const_defined?(:ClassMethods) ::ActiveRecord::ModelSchema::ClassMethods.prepend( ::RactorRailsShim::ActiveRecordModelSchemaPatch ) end end |
._install_active_model_conversion_patch ⇒ Object
Patch ActiveModel::Conversion::ClassMethods#_to_partial_path to route its
lazy class-ivar cache (@_to_partial_path ||= ...) through
IsolatedExecutionState. The cache holds a deterministic String derived
from model_name, so each Ractor can build its own. Without this, the
first render @posts / render post in a worker Ractor writes the class
ivar and dies with Ractor::IsolationError: can not set instance variables of classes/modules by non-main Ractors. Seen via ActionView's
CollectionRenderer#render_collection_derive_partial -> to_partial_path.
762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 762 def _install_active_model_conversion_patch return if @active_model_conversion_patched @active_model_conversion_patched = true _register_patch :active_model_conversion, "8.1" return unless defined?(::ActiveModel::Conversion) amc = ::ActiveModel::Conversion amc.module_eval <<-RUBY, __FILE__, __LINE__ + 1 module ClassMethods def _to_partial_path key = :"ractor_rails_shim_to_partial_path_\#{name}" v = ActiveSupport::IsolatedExecutionState[key] return v if v path = if respond_to?(:model_name) "\#{model_name.collection}/\#{model_name.element}" else element = ActiveSupport::Inflector.underscore(ActiveSupport::Inflector.demodulize(name)) collection = ActiveSupport::Inflector.tableize(name) "\#{collection}/\#{element}" end path = path.freeze ActiveSupport::IsolatedExecutionState[key] = path path end end RUBY end |
._install_active_model_naming_patch ⇒ Object
(an ActiveModel::Name holding unfrozen, unshareable Strings) on the model class. From a worker Ractor that write raises Ractor::IsolationError ("can not set instance variables of classes/modules by non-main Ractors") and reading the unshareable value raises too. Route the cache through IsolatedExecutionState (keyed by model object_id) so each Ractor builds and keeps its own ActiveModel::Name without touching the shared class ivar.
678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 678 def _install_active_model_naming_patch return if @am_naming_patched @am_naming_patched = true _register_patch :active_model_naming, "8.1" return unless defined?(::ActiveModel::Naming) mod = ::ActiveModel::Naming mod.module_eval do def model_name if Ractor.main? @_model_name ||= _rrs_compute_model_name else store = (ActiveSupport::IsolatedExecutionState[:rrs_model_names] ||= {}) store[object_id] ||= _rrs_compute_model_name end end private def _rrs_compute_model_name namespace = module_parents.detect do |n| n.respond_to?(:use_relative_model_naming?) && n.use_relative_model_naming? end ::ActiveModel::Name.new(self, namespace) end end end |
._install_active_record_core_patch ⇒ Object
Patch ActiveRecord::Core::ClassMethods#arel_table / #predicate_builder /
#type_caster. Each memoizes an unshareable value (@arel_table is an
Arel::Table, @predicate_builder a PredicateBuilder) on the shared model
class. From a worker Ractor the ||= write raises Ractor::IsolationError,
and reading the unshareable cached value also raises. Build + cache each
per-Ractor via IsolatedExecutionState (keyed by model object_id); main
keeps the original class-ivar behavior.
607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 607 def _install_active_record_core_patch return if @ar_core_patched @ar_core_patched = true _register_patch :active_record_core, "8.1" return unless defined?(::ActiveRecord::Core::ClassMethods) mod = ::ActiveRecord::Core::ClassMethods mod.module_eval do def arel_table if Ractor.main? @arel_table ||= ::Arel::Table.new(table_name, klass: self) else store = (ActiveSupport::IsolatedExecutionState[:rrs_arel_tables] ||= {}) store.fetch(object_id) { store[object_id] = ::Arel::Table.new(table_name, klass: self) } end end def predicate_builder if Ractor.main? @predicate_builder ||= ::ActiveRecord::PredicateBuilder.new( ::ActiveRecord::TableMetadata.new(self, arel_table)) else store = (ActiveSupport::IsolatedExecutionState[:rrs_predicate_builders] ||= {}) store.fetch(object_id) do store[object_id] = ::ActiveRecord::PredicateBuilder.new( ::ActiveRecord::TableMetadata.new(self, arel_table)) end end end def type_caster if Ractor.main? @type_caster ||= ::ActiveRecord::TypeCaster::Map.new(self) else store = (ActiveSupport::IsolatedExecutionState[:rrs_type_casters] ||= {}) store.fetch(object_id) { store[object_id] = ::ActiveRecord::TypeCaster::Map.new(self) } end end end end |
._install_active_record_inheritance_patch ⇒ Object
Patch ActiveRecord::Inheritance::ClassMethods#finder_needs_type_condition?.
It memoizes @finder_needs_type_condition (a Symbol) on the shared model
class via @ivar ||=. From a worker Ractor that write raises
Ractor::IsolationError. Route the value through IsolatedExecutionState
(keyed by model object_id); main keeps the original class-ivar behavior.
652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 652 def _install_active_record_inheritance_patch return if @ar_inheritance_patched @ar_inheritance_patched = true _register_patch :active_record_inheritance, "8.1" return unless defined?(::ActiveRecord::Inheritance::ClassMethods) mod = ::ActiveRecord::Inheritance::ClassMethods mod.module_eval do def finder_needs_type_condition? if Ractor.main? :true == (@finder_needs_type_condition ||= descends_from_active_record? ? :false : :true) else store = (ActiveSupport::IsolatedExecutionState[:rrs_finder_type_cond] ||= {}) store.fetch(object_id) do store[object_id] = descends_from_active_record? ? false : true end end end end end |
._install_active_record_model_schema_patch ⇒ Object
Patch ActiveRecord::ModelSchema::ClassMethods so worker Ractors do not
write the @table_name (and related) class ivars on the shared model
class. table_name memoizes via reset_table_name unless defined?(@table_name), and reset_table_name calls self.table_name =
which writes @table_name/@arel_table/etc. From a worker that write is
Ractor::IsolationError. Route the value through IsolatedExecutionState
(keyed by model object_id); main keeps the original class-ivar behavior.
557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 557 def _install_active_record_model_schema_patch return if @ar_model_schema_patched @ar_model_schema_patched = true _register_patch :active_record_model_schema, "8.1" return unless defined?(::ActiveRecord::ModelSchema::ClassMethods) mod = ::ActiveRecord::ModelSchema::ClassMethods mod.module_eval do def table_name if Ractor.main? reset_table_name unless defined?(@table_name) @table_name else store = (ActiveSupport::IsolatedExecutionState[:rrs_table_names] ||= {}) store.fetch(object_id) { store[object_id] = compute_table_name } end end def table_name=(value) value = value && value.to_s if Ractor.main? if defined?(@table_name) return if value == @table_name reset_column_information if connected? end @table_name = value @arel_table = nil @sequence_name = nil unless @explicit_sequence_name @predicate_builder = nil else (ActiveSupport::IsolatedExecutionState[:rrs_table_names] ||= {})[object_id] = value end end def reset_table_name if Ractor.main? super else table_name end end end end |
._install_active_support_error_reporter_patch ⇒ Object
Patch ActiveSupport module's @error_reporter class ivar (defined via
singleton_class.attr_accessor :error_reporter in active_support.rb:109)
to not read from a worker Ractor. ExecutionWrapper.error_reporter delegates
to ActiveSupport.error_reporter, which reads the @error_reporter ivar on
the ActiveSupport module. Workers get a fresh ErrorReporter (no subscribers
— correct for a read-only shared app where error reporting already ran
in main via the Rails.error mechanism). Called per-request via
ActionDispatch::Executor middleware.
295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 295 def _install_active_support_error_reporter_patch return if @error_reporter_patched @error_reporter_patched = true _register_patch :error_reporter, "8.1" return unless defined?(::ActiveSupport) er_key = :ractor_rails_shim_active_support_error_reporter er_key_str = er_key.inspect ::ActiveSupport.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def error_reporter v = ActiveSupport::IsolatedExecutionState[#{er_key_str}] return v unless v.nil? if Ractor.main? && instance_variable_defined?(:@error_reporter) @error_reporter else built = ActiveSupport::ErrorReporter.new ActiveSupport::IsolatedExecutionState[#{er_key_str}] = built built end end def error_reporter=(val) ActiveSupport::IsolatedExecutionState[#{er_key_str}] = val @error_reporter = val if Ractor.main? val end RUBY end |
._install_activerecord_configurations_patch ⇒ Object
Patch ActiveRecord::Core.configurations / configurations= to route the
raw @@configurations class variable (which a non-main Ractor cannot
read or write) through IsolatedExecutionState, with a shareable
(deep-frozen) fallback for worker Ractors. Connection establishment in a
worker (ConnectionHandler#establish_connection -> resolve_pool_config
-> ActiveRecord::Base.configurations) otherwise dies on the class
variable. Captured in the main Ractor at prepare/make-shareable time.
796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 796 def _install_activerecord_configurations_patch return if @ar_configurations_patched @ar_configurations_patched = true _register_patch :activerecord_configurations, "8.1" return unless defined?(::ActiveRecord::Core) if Ractor.main? begin cfg = ::ActiveRecord::Base.configurations cfg = Ractor.make_shareable(cfg) if cfg if cfg verbose, $VERBOSE = $VERBOSE, nil begin const_set(:AR_CONFIGURATIONS_SHAREABLE, cfg) ensure $VERBOSE = verbose end end rescue => e # best-effort end end key = :ractor_rails_shim_ar_configurations key_str = key.inspect # ActiveRecord::Base gets `configurations` via ActiveSupport::Concern's # `class_methods` (it copies the method onto Base's singleton class), so # patching Core.singleton_class alone is not enough — redefine on Base's # singleton class directly. `@@configurations` resolves through the # include chain (Core's class var) in the main ractor; the worker branch # never touches the class var. ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def configurations v = ActiveSupport::IsolatedExecutionState[#{key_str}] return v unless v.nil? if Ractor.main? ActiveRecord::Core.class_variable_get(:@@configurations) else RactorRailsShim::AR_CONFIGURATIONS_SHAREABLE end end def configurations=(config) ActiveSupport::IsolatedExecutionState[#{key_str}] = config ActiveRecord::Core.class_variable_set(:@@configurations, config) if Ractor.main? end RUBY end |
._install_activerecord_connection_handler_patch ⇒ Object
Patch ActiveRecord::Base to route default_connection_handler through IES, and ensure connection_handler returns the per-Ractor handler. In the main ractor, falls back to the original default_connection_handler (set at core.rb:248). In workers, falls back to nil (correct — workers must call init_worker_ar_connections! to establish their own handler). Also patches retrieve_connection / connected? / connection_pool to tolerate nil handler (raise a clear error message instead of NoMethodError on nil).
1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1266 def _install_activerecord_connection_handler_patch return if @ar_conn_handler_patched @ar_conn_handler_patched = true _register_patch :activerecord_connection_handler, "8.1" return unless defined?(::ActiveRecord::ConnectionHandling) # Capture configs at install time if AR is already loaded (main ractor). _capture_ar_configurations! if Ractor.main? # Capture the main ractor's default_connection_handler value BEFORE we # override the method below (the override shadows the original # class_attribute reader). The class_attribute reader (already patched # by the shim) routes through IES, so read the value now. Store it in # CLASS_ATTR_VALUES so the patched reader can find it, and seed IES so # connection_handler finds it immediately. dch_key = :ractor_rails_shim_ar_default_connection_handler dch_key_str = dch_key.inspect if Ractor.main? begin orig_handler = ::ActiveRecord::Base.default_connection_handler if orig_handler RactorRailsShim::CLASS_ATTR_VALUES[:__ractor_rails_shim_ar_default_connection_handler__] = orig_handler ActiveSupport::IsolatedExecutionState[dch_key] = orig_handler end rescue => e # Best-effort end end # Patch default_connection_handler to route through IES. # The class_attribute reader for default_connection_handler is already # patched by the shim (it's in the known-unshareable skip list → nil # in workers). We override the class method to return the per-Ractor # handler if set, then fall back to the original (main only) or nil. ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def default_connection_handler v = ActiveSupport::IsolatedExecutionState[#{dch_key_str}] return v unless v.nil? if Ractor.main? cv = RactorRailsShim::CLASS_ATTR_VALUES[:__ractor_rails_shim_ar_default_connection_handler__] return cv if cv end nil end def default_connection_handler=(val) ActiveSupport::IsolatedExecutionState[#{dch_key_str}] = val end RUBY # Also ensure connection_handler (which Rails already routes through IES # at core.rb:132-138) works. Rails' implementation: # def self.connection_handler # ActiveSupport::IsolatedExecutionState[:active_record_connection_handler] || default_connection_handler # end # This is already correct — if the worker sets the IES key via # init_worker_ar_connections!, connection_handler returns it. If not, # it falls back to default_connection_handler (nil in workers). # # We just need to make sure connection_pool / retrieve_connection # give a clear error message when the handler is nil (instead of # NoMethodError: undefined method `retrieve_connection_pool' for nil). ::ActiveRecord::ConnectionHandling.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def connection_pool handler = connection_handler unless handler raise ActiveRecord::ConnectionNotEstablished, "No connection handler for Ractor \#{Ractor.current.object_id}. " \ "Call RactorRailsShim.init_worker_ar_connections! in each worker " \ "Ractor before serving requests." end handler.retrieve_connection_pool(connection_specification_name, role: current_role, shard: current_shard, strict: true) end def retrieve_connection handler = connection_handler unless handler raise ActiveRecord::ConnectionNotEstablished, "No connection handler for Ractor \#{Ractor.current.object_id}. " \ "Call RactorRailsShim.init_worker_ar_connections! in each worker " \ "Ractor before serving requests." end handler.retrieve_connection(connection_specification_name, role: current_role, shard: current_shard) end def connected? handler = connection_handler return false unless handler handler.connected?(connection_specification_name, role: current_role, shard: current_shard) end RUBY end |
._install_activerecord_db_config_handlers_patch ⇒ Object
Patch ActiveRecord::DatabaseConfigurations.db_config_handlers (a
singleton_class.attr_accessor, i.e. a class instance variable on the
DatabaseConfigurations class) to route through IES with a shareable
fallback. The value is an Array of adapter-registered handler Procs
(register_db_config_handler { |env,name,url,config| ... }). Class
instance variables are per-Ractor, so a worker's slot is empty even if
main set one — and the worker cannot read main's. The handler Procs
themselves CAN be made shareable (verified: the sqlite3 handler captures
only the shareable HashConfig constant), so we deep-freeze the Array +
each Proc in main and expose it as a shareable constant the worker reads.
ConnectionHandler#establish_connection -> resolve_pool_config ->
DatabaseConfigurations#resolve -> build_db_config_from_hash calls
these Procs, so they must be shareable AND callable cross-Ractor.
857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 857 def _install_activerecord_db_config_handlers_patch return if @ar_dbch_patched @ar_dbch_patched = true _register_patch :activerecord_db_config_handlers, "8.1" return unless defined?(::ActiveRecord::DatabaseConfigurations) if Ractor.main? begin handlers = ::ActiveRecord::DatabaseConfigurations.db_config_handlers # Make each handler Proc shareable (freezes its binding). A shareable # Proc is callable from any Ractor. handlers.each { |h| Ractor.make_shareable(h) rescue nil } shareable = Ractor.make_shareable(handlers.dup) verbose, $VERBOSE = $VERBOSE, nil begin const_set(:AR_DB_CONFIG_HANDLERS_SHAREABLE, shareable) ensure $VERBOSE = verbose end rescue => e # best-effort end end key = :ractor_rails_shim_ar_db_config_handlers key_str = key.inspect ::ActiveRecord::DatabaseConfigurations.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def db_config_handlers v = ActiveSupport::IsolatedExecutionState[#{key_str}] return v unless v.nil? if Ractor.main? @db_config_handlers else RactorRailsShim::AR_DB_CONFIG_HANDLERS_SHAREABLE end end def db_config_handlers=(val) ActiveSupport::IsolatedExecutionState[#{key_str}] = val @db_config_handlers = val if Ractor.main? end RUBY end |
._install_activerecord_deduplicable_patch ⇒ Object
Patch Deduplicable::ClassMethods#registry to route the lazy class instance
variable @registry through IES. registry returns @registry ||= {} —
a mutable Hash used to deduplicate column metadata objects. It's called
during schema introspection (Post.all -> columns -> new_column_from_field
-> fetch_type_metadata -> Deduplicable.new -> deduplicate -> registry).
The class instance variable write fails from a non-main Ractor.
Fix: route through IES so each Ractor builds its own registry Hash.
1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1015 def _install_activerecord_deduplicable_patch return if @ar_deduplicable_patched @ar_deduplicable_patched = true _register_patch :activerecord_deduplicable, "8.1" return unless defined?(::ActiveRecord::ConnectionAdapters::Deduplicable) ::ActiveRecord::ConnectionAdapters::Deduplicable::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def registry key = :"ractor_rails_shim_dedup_registry_\#{name || object_id}" v = ActiveSupport::IsolatedExecutionState[key] return v unless v.nil? h = {} ActiveSupport::IsolatedExecutionState[key] = h h end RUBY end |
._install_activerecord_delegation_patch ⇒ Object
Patch ActiveRecord::Delegation.uncacheable_methods to route the lazy class instance variable @uncacheable_methods through IES.
uncacheable_methods is a class method on the Delegation module:
`@uncacheable_methods ||= (delegated_classes.flat_map(&:public_instance_methods)
- Relation.public_instance_methods).to_set.freeze
. It's read duringmethod_missingon relation delegate classes (e.g. when Kaminari callsPost.page(1).per(10)—perisn't a standard Relation method, soClassSpecificRelation#method_missingchecksuncacheable_methods` to decide whether to delegate). The class instance variable write fails from a non-main Ractor (Ractor::IsolationError).
Fix: route through IES so each Ractor computes + caches its own Set. The computation is deterministic (same delegated_classes everywhere). String-eval'd (no captured binding), callable from any Ractor.
1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1405 def _install_activerecord_delegation_patch return if @ar_delegation_patched @ar_delegation_patched = true _register_patch :activerecord_delegation, "8.1" return unless defined?(::ActiveRecord::Delegation) ::ActiveRecord::Delegation.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def uncacheable_methods key = :ractor_rails_shim_ar_uncacheable_methods v = ActiveSupport::IsolatedExecutionState[key] return v unless v.nil? result = ( delegated_classes.flat_map(&:public_instance_methods) - ActiveRecord::Relation.public_instance_methods ).to_set.freeze ActiveSupport::IsolatedExecutionState[key] = result result end RUBY end |
._install_activerecord_find_by_cache_patch ⇒ Object
ActiveRecord::Base#cached_find_by_statement reads
@find_by_statement_cache[connection.prepared_statements] (a Hash whose
values are Concurrent::Maps) and calls cache.compute_if_absent(key).
Concurrent::Map is unshareable, so make_app_shareable! replaces the
maps with frozen Hashes whose values end up nil — and Hash has no
compute_if_absent anyway. In a worker Ractor this raises
NoMethodError: undefined method 'compute_if_absent' for nil, breaking
find / find_by / take (but not where, which doesn't use the
cache). Fix: in non-main Ractors, build the per-find-statement cache in
IsolatedExecutionState (per-Ractor, mutable) keyed by the model class
and connection.prepared_statements. Main keeps the original
Concurrent::Map-backed behavior via super.
1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1488 def _install_activerecord_find_by_cache_patch return if @activerecord_find_by_cache_patched @activerecord_find_by_cache_patched = true _register_patch :activerecord_find_by_cache, "8.1" return unless defined?(::ActiveRecord::Base) ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def cached_find_by_statement(connection, key, &block) return super if Ractor.main? cache = (ActiveSupport::IsolatedExecutionState[:"ractor_rails_shim_find_by_cache_\#{object_id}"] ||= {}) prepared = connection.prepared_statements sub = (cache[prepared] ||= {}) if sub.key?(key) sub[key] else sub[key] = ::ActiveRecord::StatementCache.create(connection, &block) end end RUBY end |
._install_activerecord_migration_patch ⇒ Object
Patch ActiveRecord::Migrator.migrations_paths (a singleton attr_accessor
reading the @migrations_paths class ivar) and
ActiveRecord::Migration::CheckPending (the dev pending-migration
middleware) to be Ractor-safe. In dev, CheckPending runs on every request
and reads Migrator.migrations_paths + mutates its own @watcher /
@needs_check ivars on the (frozen, shared) middleware instance. Route
the class ivar and the instance ivars through IsolatedExecutionState so
each worker reads the main Ractor's migrations paths and builds its own
watcher. This keeps the dev pending-migration guard working under kino
:ractor instead of stripping the middleware.
1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1642 def _install_activerecord_migration_patch return if @activerecord_migration_patched @activerecord_migration_patched = true _register_patch :activerecord_migration, "8.1" return unless defined?(::ActiveRecord::Migrator) mig = ::ActiveRecord::Migrator mp_key = :ractor_rails_shim_migrator_migrations_paths mp_key_str = mp_key.inspect mig.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def migrations_paths v = ActiveSupport::IsolatedExecutionState[#{mp_key_str}] return v unless v.nil? if Ractor.main? && instance_variable_defined?(:@migrations_paths) v = @migrations_paths ActiveSupport::IsolatedExecutionState[#{mp_key_str}] = v v else ["db/migrate"].freeze end end def migrations_paths=(val) ActiveSupport::IsolatedExecutionState[#{mp_key_str}] = val @migrations_paths = val if Ractor.main? val end RUBY return unless defined?(::ActiveRecord::Migration::CheckPending) cp = ::ActiveRecord::Migration::CheckPending w_key = :ractor_rails_shim_check_pending_watcher nc_key = :ractor_rails_shim_check_pending_needs_check w_key_str = w_key.inspect nc_key_str = nc_key.inspect cp.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def call(env) @mutex.synchronize do watcher = ActiveSupport::IsolatedExecutionState[#{w_key_str}] if watcher.nil? watcher = ActiveSupport::IsolatedExecutionState[#{w_key_str}] = build_watcher do ActiveSupport::IsolatedExecutionState[#{nc_key_str}] = true ::ActiveRecord::Migration.check_pending_migrations ActiveSupport::IsolatedExecutionState[#{nc_key_str}] = false end end needs_check = ActiveSupport::IsolatedExecutionState[#{nc_key_str}] needs_check = true if needs_check.nil? if needs_check watcher.execute else watcher.execute_if_updated end end @app.call(env) end RUBY end |
._install_activerecord_model_classes_patch ⇒ Object
Register + run the model-class shareability patch (Blocker: AR model class lazy class-ivar initialization from workers).
541 542 543 544 545 546 547 548 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 541 def _install_activerecord_model_classes_patch return if @ar_model_classes_patched @ar_model_classes_patched = true _register_patch :activerecord_model_classes, "8.1" return unless defined?(::ActiveRecord::Base) _share_model_classes! if Ractor.main? _share_active_record_internals! if Ractor.main? end |
._install_activerecord_model_schema_patch ⇒ Object
Patch ActiveRecord::ModelSchema::ClassMethods lazy class-ivar caches
(symbol_column_to_string, content_columns, column_defaults) to
route through IsolatedExecutionState. Each Ractor builds its own cache
(deterministic from columns/columns_hash, which are warmed in main
and read-only in workers). Without this, the first worker call that
misses the cache tries to WRITE the class ivar (@symbol_column_to_string_name_hash ||= ...) and dies with Ractor::IsolationError: can not set instance variables of classes/modules by non-main Ractors. Seen via Devise's
clean_up_passwords -> respond_to? -> symbol_column_to_string.
714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 714 def _install_activerecord_model_schema_patch return if @ar_model_schema_symbol_patched @ar_model_schema_symbol_patched = true _register_patch :activerecord_model_schema, "8.1" return unless defined?(::ActiveRecord::ModelSchema) ::ActiveRecord::ModelSchema::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def symbol_column_to_string(name_symbol) key = :"ractor_rails_shim_symbol_column_to_string_\#{self.name}" v = ActiveSupport::IsolatedExecutionState[key] return v[name_symbol] if v hash = column_names.index_by(&:to_sym) ActiveSupport::IsolatedExecutionState[key] = hash hash[name_symbol] end def content_columns key = :"ractor_rails_shim_content_columns_\#{self.name}" v = ActiveSupport::IsolatedExecutionState[key] return v if v cols = columns.reject do |c| c.name == primary_key || c.name == inheritance_column || c.name.end_with?("_id", "_count") end.freeze ActiveSupport::IsolatedExecutionState[key] = cols cols end def column_defaults key = :"ractor_rails_shim_column_defaults_\#{self.name}" v = ActiveSupport::IsolatedExecutionState[key] return v if v defaults = _default_attributes.deep_dup.to_hash.freeze ActiveSupport::IsolatedExecutionState[key] = defaults defaults end RUBY end |
._install_activerecord_module_attrs_patch ⇒ Object
Patch ActiveRecord module-level singleton_class.attr_accessor attributes
(schema_cache_ignored_tables, database_cli, etc.) to route through IES
with shareable fallbacks. These are class instance variables on the
ActiveRecord module that workers can't read/write. Each is an Array or
Hash of simple literals, so they can be deep-frozen and shared.
959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 959 def _install_activerecord_module_attrs_patch return if @ar_module_attrs_patched @ar_module_attrs_patched = true _register_patch :activerecord_module_attrs, "8.1" return unless defined?(::ActiveRecord) # [method_name, const_name] pairs. The const holds the shareable snapshot. attrs = [ [:schema_cache_ignored_tables, :AR_SCHEMA_CACHE_IGNORED_TABLES_SHAREABLE], [:database_cli, :AR_DATABASE_CLI_SHAREABLE], ] attrs.each do |method_name, const_name| if Ractor.main? begin val = ::ActiveRecord.public_send(method_name) shareable = Ractor.make_shareable(val.is_a?(::Array) ? val.dup : val) verbose, $VERBOSE = $VERBOSE, nil begin const_set(const_name, shareable) ensure $VERBOSE = verbose end rescue => e # best-effort end end key = :"ractor_rails_shim_ar_#{method_name}" key_str = key.inspect const_str = const_name.to_s ::ActiveRecord.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{method_name} v = ActiveSupport::IsolatedExecutionState[#{key_str}] return v unless v.nil? if Ractor.main? @#{method_name} else RactorRailsShim::#{const_str} end end def #{method_name}=(val) ActiveSupport::IsolatedExecutionState[#{key_str}] = val @#{method_name} = val if Ractor.main? end RUBY end end |
._install_activerecord_pool_config_patch ⇒ Object
Patch ActiveRecord::ConnectionAdapters::PoolConfig#initialize to skip
writing to the INSTANCES ObjectSpace::WeakMap registry in non-main
Ractors. This is the first wall a worker hits when establishing a
connection (ConnectionHandler#establish_connection ->
resolve_pool_config -> PoolConfig.new -> INSTANCES[self] = self).
INSTANCES is a private_constant ObjectSpace::WeakMap. A WeakMap is
intrinsically unshareable (it can't be frozen / made shareable), and a
non-main Ractor cannot access the constant at all (Ractor::IsolationError:
"can not access non-shareable objects in constant ... by non-main ractor").
The registry is ONLY used by the class methods discard_pools! and
disconnect_all! (which iterate all pool configs to disconnect/reload).
Those are called during reloading (dev) and explicit disconnect — never
in a read-only production worker serving requests. So skipping the
registry write in workers is safe: workers manage their own per-Ractor
handler + pools, and the main ractor's registry stays intact for reload.
We redefine initialize via string eval (no captured binding) so it's
callable from any Ractor. The body replicates the original exactly except
the final INSTANCES[self] = self is guarded by Ractor.main?. The
private INSTANCES constant is accessible via constant lookup because
the method is defined on PoolConfig itself.
1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1090 def _install_activerecord_pool_config_patch return if @ar_pool_config_patched @ar_pool_config_patched = true _register_patch :activerecord_pool_config, "8.1" return unless defined?(::ActiveRecord::ConnectionAdapters::PoolConfig) ::ActiveRecord::ConnectionAdapters::PoolConfig.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def initialize(connection_class, db_config, role, shard) super() @server_version = nil self.connection_descriptor = connection_class @db_config = db_config @role = role @shard = shard @pool = nil INSTANCES[self] = self if Ractor.main? end RUBY end |
._install_activerecord_primary_key_patch ⇒ Object
Patch ActiveRecord::AttributeMethods::PrimaryKey#primary_key and #composite_primary_key? to not read the PRIMARY_KEY_NOT_SET constant.
The original code: reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key)
reads the constant on every call. PRIMARY_KEY_NOT_SET is a BasicObject
(can't be frozen, can't be made shareable), so reading the constant from
a worker Ractor raises Ractor::IsolationError — even if @primary_key is
already set to the real value.
Fix: replace the sentinel check with a shareable-snapshot lookup. At _share_model_classes! time, each model's primary_key is warmed in main and stored in AR_PRIMARY_KEYS_SHAREABLE (a frozen Hash). The patched primary_key method checks IES first (per-Ractor), then the shareable snapshot, then falls back to the original logic in the main ractor. Workers never read the constant. String-eval'd (no captured binding).
1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1440 def _install_activerecord_primary_key_patch return if @ar_primary_key_patched @ar_primary_key_patched = true _register_patch :activerecord_primary_key, "8.1" return unless defined?(::ActiveRecord::AttributeMethods::PrimaryKey::ClassMethods) mod = ::ActiveRecord::AttributeMethods::PrimaryKey::ClassMethods mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def primary_key key = :"ractor_rails_shim_pk_\#{name || object_id}" v = ActiveSupport::IsolatedExecutionState[key] return v unless v.nil? if Ractor.main? reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key) v = @primary_key ActiveSupport::IsolatedExecutionState[key] = v v else RactorRailsShim::AR_PRIMARY_KEYS_SHAREABLE[name] end end def composite_primary_key? key = :"ractor_rails_shim_pk_\#{name || object_id}" v = ActiveSupport::IsolatedExecutionState[key] return v.is_a?(::Array) unless v.nil? if Ractor.main? reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key) @primary_key.is_a?(::Array) else pk = RactorRailsShim::AR_PRIMARY_KEYS_SHAREABLE[name] pk.is_a?(::Array) end end RUBY end |
._install_activerecord_query_constraints_patch ⇒ Object
Patch Persistence::ClassMethods#query_constraints_list and #has_query_constraints?
to route the lazy @query_constraints_list class ivar through IES.
query_constraints_list does @query_constraints_list ||= <computation>
— the class ivar write fails from a non-main Ractor. Called during
Post.first -> ordered_relation -> _order_columns.
1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1038 def _install_activerecord_query_constraints_patch return if @ar_query_constraints_patched @ar_query_constraints_patched = true _register_patch :activerecord_query_constraints, "8.1" return unless defined?(::ActiveRecord::Persistence::ClassMethods) ::ActiveRecord::Persistence::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def query_constraints_list key = :"ractor_rails_shim_qcl_\#{name || object_id}" v = ActiveSupport::IsolatedExecutionState[key] return v unless v.nil? result = if base_class? || primary_key != base_class.primary_key primary_key if primary_key.is_a?(::Array) else base_class.query_constraints_list end ActiveSupport::IsolatedExecutionState[key] = result result end def has_query_constraints? key = :"ractor_rails_shim_qcl_\#{name || object_id}" v = ActiveSupport::IsolatedExecutionState[key] return !v.nil? unless v.nil? result = query_constraints_list !result.nil? end RUBY end |
._install_activerecord_query_logs_patch ⇒ Object
Patch ActiveRecord::QueryLogs#tag_content. It reads the @handlers and
@formatter class ivars (populated from config.active_record.query_log_tags
during boot) on every SQL statement, so a worker Ractor raises
Ractor::IsolationError. cached_comment is a thread_mattr_accessor (already
Ractor-safe), so only the handlers/formatter need handling. Capture a
shareable snapshot in main (snapshot_query_logs!, post-boot) and have
workers build the comment from it — query-log tags then work in workers
exactly as in dev's main Ractor.
1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1547 def _install_activerecord_query_logs_patch return if @query_logs_patched @query_logs_patched = true _register_patch :query_logs, "8.1" return unless defined?(::ActiveRecord::QueryLogs) ql = ::ActiveRecord::QueryLogs # tag_content is defined as a SINGLETON method on ActiveRecord::QueryLogs # (not an instance method), so alias the singleton method (not an # instance one) and fall back to it for the main Ractor / when the # snapshot is unavailable. ql.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 alias_method :__ractors_original_tag_content, :tag_content def tag_content(connection) return __ractors_original_tag_content(connection) if Ractor.main? snap = ::RactorRailsShim::QUERY_LOGS_SNAPSHOT return __ractors_original_tag_content(connection) unless snap format = snap[:format] formatter = case format when :sqlcommenter then ::ActiveRecord::QueryLogs::SQLCommenter else ::ActiveRecord::QueryLogs::LegacyFormatter end return nil unless formatter context = ActiveSupport::ExecutionContext.to_h context[:connection] ||= connection pairs = snap[:handlers].filter_map do |(key, kind, data)| val = case kind when :get_key then context[key] when :identity then data else nil end formatter.format(key, val) unless val.nil? end formatter.join(pairs) end RUBY end |
._install_activerecord_query_transformers_patch ⇒ Object
Patch ActiveRecord.query_transformers to route through IES with a
shareable fallback. query_transformers is a singleton_class .attr_accessor (a class instance variable on the ActiveRecord module)
holding an Array of transformer objects (e.g. ActiveRecord::QueryLogs).
DatabaseStatements#preprocess_query reads it on every query:
ActiveRecord.query_transformers.each { |t| t.call(sql, self) }.
Class instance variables are per-Ractor, so a worker's @query_transformers
is nil (set in main at boot via self.query_transformers = [], then
<< QueryLogs in the railtie). The transformer objects themselves ARE
shareable (they're Classes/modules), so we deep-freeze the Array in main
and expose it as a shareable constant the worker reads. Same pattern as
_install_activerecord_db_config_handlers_patch.
913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 913 def _install_activerecord_query_transformers_patch return if @ar_query_transformers_patched @ar_query_transformers_patched = true _register_patch :activerecord_query_transformers, "8.1" return unless defined?(::ActiveRecord) if Ractor.main? begin transformers = ::ActiveRecord.query_transformers transformers.each { |t| Ractor.make_shareable(t) rescue nil } shareable = Ractor.make_shareable(transformers.dup) verbose, $VERBOSE = $VERBOSE, nil begin const_set(:AR_QUERY_TRANSFORMERS_SHAREABLE, shareable) ensure $VERBOSE = verbose end rescue => e # best-effort end end key = :ractor_rails_shim_ar_query_transformers key_str = key.inspect ::ActiveRecord.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def query_transformers v = ActiveSupport::IsolatedExecutionState[#{key_str}] return v unless v.nil? if Ractor.main? @query_transformers else RactorRailsShim::AR_QUERY_TRANSFORMERS_SHAREABLE end end def query_transformers=(val) ActiveSupport::IsolatedExecutionState[#{key_str}] = val @query_transformers = val if Ractor.main? end RUBY end |
._install_activerecord_quoting_cache_patch ⇒ Object
Patch the adapter quoting caches (QUOTED_COLUMN_NAMES /
QUOTED_TABLE_NAMES) to use per-Ractor storage instead of the
unshareable Concurrent::Map constants.
Each adapter (SQLite3, MySQL, PostgreSQL) defines these as
Concurrent::Map.new constants in its Quoting module, and the
quote_column_name / quote_table_name class methods lazily populate
them via MAP[name] ||= <quoting_logic>.freeze. Concurrent::Map is
intrinsically unshareable (no #freeze), so a worker Ractor cannot
access the constant at all (Ractor::IsolationError: "can not access
non-shareable objects in constant ..."). This is the fourth wall a worker
hits: during Post.count -> Arel traversal -> quote_table_name ->
QUOTED_TABLE_NAMES[name] ||= ....
Fix: redefine quote_column_name / quote_table_name on each adapter's
Quoting::ClassMethods module to use a per-Ractor Hash cache (stored in
IES, keyed by the adapter class name). Each Ractor builds its own
mutable cache on first access. The quoting logic is replicated per
adapter (it's simple, stable string manipulation). String-eval'd (no
captured binding), callable from any Ractor.
1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1212 def _install_activerecord_quoting_cache_patch return if @ar_quoting_patched @ar_quoting_patched = true _register_patch :activerecord_quoting_cache, "8.1" # [module_path, column_logic, table_logic] per adapter. adapters = [ ["ActiveRecord::ConnectionAdapters::SQLite3::Quoting", %q{%Q("#{name.to_s.gsub('"', '""')}").freeze}, %q{%Q("#{name.to_s.gsub('"', '""').gsub(".", "\".\"")}").freeze}], ["ActiveRecord::ConnectionAdapters::MySQL::Quoting", %q{"`#{name.to_s.gsub('`', '``')}`".freeze}, %q{"`#{name.to_s.gsub('`', '``').gsub(".", "`.`")}`".freeze}], ["ActiveRecord::ConnectionAdapters::PostgreSQL::Quoting", %q{::PG::Connection.quote_ident(name.to_s).freeze}, %q{::ActiveRecord::ConnectionAdapters::PostgreSQL::Utils.extract_schema_qualified_name(name.to_s).quoted.freeze}], ] adapters.each do |mod_path, column_logic, table_logic| mod = begin mod_path.split("::").inject(Object) { |ns, n| ns.const_get(n, false) } rescue nil end next unless mod klass_mod = mod.const_get(:ClassMethods, false) rescue next next unless klass_mod.method_defined?(:quote_column_name) klass_mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def quote_column_name(name) key = :"ractor_rails_shim_quoted_cols_\#{self.name}" cache = ActiveSupport::IsolatedExecutionState[key] cache ||= (ActiveSupport::IsolatedExecutionState[key] = {}) cache[name] ||= (#{column_logic}) end def quote_table_name(name) key = :"ractor_rails_shim_quoted_tables_\#{self.name}" cache = ActiveSupport::IsolatedExecutionState[key] cache ||= (ActiveSupport::IsolatedExecutionState[key] = {}) cache[name] ||= (#{table_logic}) end RUBY end end |
._install_activerecord_reaper_patch ⇒ Object
Patch ConnectionPool::Reaper#run to no-op in non-main Ractors.
ConnectionPool#initialize (connection_pool.rb:307) calls @reaper.run,
which calls Reaper.register_pool (a class method). register_pool
reads/writes the Reaper class's instance variables (@mutex, @pools,
background reaper thread. Class instance variables are off-limits to
non-main Ractors (Ractor::IsolationError), so this is the second wall a
worker hits during establish_connection -> ConnectionPool.new.
The reaper is a background maintenance thread that periodically reaps dead-thread connections, flushes idle connections, and keepalives stale ones. In a worker Ractor this is neither safe (can't share the reaper thread or its class-ivar registry across Ractors) nor essential: each Ractor owns its own connection pool, and when the Ractor exits its pool is garbage-collected with it. Connection health for long-lived workers can be addressed later with a per-Ractor reaper if needed; for now, no-op'ing registration unblocks connection establishment.
register_pool is only called from Reaper#run, so patching run to
return early in non-main Ractors fully prevents the class-ivar access and
the thread spawn. The pool itself still functions normally.
1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1132 def _install_activerecord_reaper_patch return if @ar_reaper_patched @ar_reaper_patched = true _register_patch :activerecord_reaper, "8.1" return unless defined?(::ActiveRecord::ConnectionAdapters::ConnectionPool::Reaper) ::ActiveRecord::ConnectionAdapters::ConnectionPool::Reaper.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def run return unless frequency && frequency > 0 return unless Ractor.main? self.class.register_pool(pool, frequency) end RUBY end |
._install_activerecord_relation_delegate_cache_patch ⇒ Object
Blockers 3: ActiveRecord model classes cache relation-delegate classes
in the @relation_delegate_cache class instance variable (set in
DelegateCache#initialize_relation_delegate_cache,
activerecord/relation/delegation.rb:31-44). The cache is a plain (mutable)
Hash mapping each delegated class (ActiveRecord::Relation, etc.) to an
anonymous delegate Class. From a worker Ractor, reading the class ivar
raises Ractor::IsolationError ("can not get unshareable values from
instance variables of classes/modules from non-main Ractors
(@relation_delegate_cache from Post)") — even a plain Post.page(1).
The delegate Classes themselves ARE shareable (verified: Ractor.shareable?(Post::ActiveRecord_Relation) == true). Only the enclosing Hash is mutable (unshareable). So we deep-freeze the cache (Ractor.make_shareable) in the main Ractor at prepare/make-shareable time. A class ivar whose value is shareable is readable from a worker Ractor (unlike class variables, which always raise). Freezing is safe: the cache is populated once per class at load time and never mutated afterwards (each relation-delegate Class is const_set as a private constant on the model class).
282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 282 def _install_activerecord_relation_delegate_cache_patch return if @ar_rdc_patched @ar_rdc_patched = true _register_patch :activerecord_relation_delegate_cache, "8.1" return unless defined?(::ActiveRecord::Base) mod = ::ActiveRecord::Delegation::DelegateCache mod.module_eval do # The default implementation reads the delegate class from the # `@relation_delegate_cache` *class instance variable* — an unshareable # Hash populated lazily on the model class. From a worker Ractor that # value is unreadable (Ractor::IsolationError: "can not get unshareable # values from instance variables ... (@relation_delegate_cache from # Post)"). `initialize_relation_delegate_cache` ALSO const_sets each # delegate class onto the model (e.g. `Post::ActiveRecord_Relation`), # and that constant is shareable. So read the delegate class via the # constant instead of the class ivar — Ractor-safe with no per-worker # rebuild. def relation_delegate_class(klass) const_get(klass.name.gsub("::", "_")) end # Keep building + const_setting the delegate classes (shareable), but # stop stashing them in the unshareable `@relation_delegate_cache` ivar. # The ivar is left a frozen empty Hash so any (now-unused) reader of it # is still Ractor-safe. def initialize_relation_delegate_cache @relation_delegate_cache = {}.freeze ::ActiveRecord::Delegation.delegated_classes.each do |k| delegate = Class.new(k) { include ::ActiveRecord::Delegation::ClassSpecificRelation } include_relation_methods(delegate) mangled_name = k.name.gsub("::", "_") const_set mangled_name, delegate private_constant mangled_name end end end _share_relation_delegate_caches! if Ractor.main? end |
._install_activerecord_serialize_cast_value_patch ⇒ Object
Patch ActiveModel::Type::SerializeCastValue::ClassMethods #serialize_cast_value_compatible? to route the lazy class instance variable @serialize_cast_value_compatible through IES.
This method lazily caches a boolean: return @x if defined?(@x); @x = <computation>. It's called during type-map initialization (every
adapter's initialize_type_map creates type objects whose constructors
eagerly call this to precompute the value). The class instance variable
write fails from a non-main Ractor (Ractor::IsolationError: "can not set
instance variables of classes/modules by non-main Ractors").
Fix: route through IES so each Ractor computes + caches its own value.
The computation is deterministic (compares ancestor positions of two
methods), so per-Ractor recomputation yields the same result. Each
including class gets its own IES key (keyed by self.name). String-eval'd
(no captured binding), callable from any Ractor.
1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1372 def _install_activerecord_serialize_cast_value_patch return if @ar_serialize_cast_patched @ar_serialize_cast_patched = true _register_patch :activerecord_serialize_cast_value, "8.1" return unless defined?(::ActiveModel::Type::SerializeCastValue) ::ActiveModel::Type::SerializeCastValue::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def serialize_cast_value_compatible? key = :"ractor_rails_shim_scv_\#{name || object_id}" v = ActiveSupport::IsolatedExecutionState[key] return v unless v.nil? result = ancestors.index(instance_method(:serialize_cast_value).owner) <= ancestors.index(instance_method(:serialize).owner) ActiveSupport::IsolatedExecutionState[key] = result result end RUBY end |
._install_activerecord_transaction_callbacks_patch ⇒ Object
In the shared :ractor graph, ActiveRecord model classes can end up with a
nil __callbacks (the class_attribute value can't be made shareable when
it holds unshareable callback Procs, so the shim's shareable fallback
returns nil). has_transactional_callbacks? calls the generated
_rollback_callbacks / _commit_callbacks / _before_commit_callbacks
readers, which do __callbacks[:kind] directly — bypassing the
run_callbacks_with_nil_safe guard. With a nil __callbacks that raises
NoMethodError: undefined method '[]' for nil, breaking every
save / update / destroy (they run inside a transaction). Guard it:
a nil / empty callback table means no transactional callbacks.
1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1710 def _install_activerecord_transaction_callbacks_patch return if @activerecord_transaction_callbacks_patched @activerecord_transaction_callbacks_patched = true _register_patch :activerecord_transaction_callbacks, "8.1" return unless defined?(::ActiveRecord::Base) ar = ::ActiveRecord::Base ar.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def has_transactional_callbacks? cb = __callbacks return false unless cb !((cb[:rollback] || []).empty?) || !((cb[:commit] || []).empty?) || !((cb[:before_commit] || []).empty?) end RUBY end |
._install_all_framework_patches ⇒ Object
Public API: run after Rails.application.initialize! and BEFORE spawning
worker Ractors. Makes every registered constant shareable (deep-freeze).
Constants that didn't exist at install time (e.g. Rails::Railtie, loaded
after module Rails opens) get fixed here. Idempotent; safe to call
multiple times. Must run in the main Ractor.
NOTE: this does NOT build the framework-config shareable fallback. That
step is folded into make_app_shareable! because some class_attribute /
mattr_accessor values reference the app graph, and making them shareable
must happen AFTER the app itself is already frozen (otherwise the app
gets frozen prematurely and precompute/proc-replacement can't mutate
it). If you call prepare_for_ractors! standalone (without
make_app_shareable!), worker Ractors will see nil for framework config
values that couldn't be shared without freezing the app — set them
explicitly per worker, or use make_app_shareable!.
Shared list of every framework patch install method. Both
prepare_for_ractors! (pre-worker boot) and make_app_shareable!
(post-boot, pre-freeze) call this, so the two no longer duplicate the
literal _install_* list. Each _install_* is idempotent (guarded by
its own @*_patched flag), so calling the full set at either point is a
no-op for already-applied patches. Duplicates from the original
prepare_for_ractors! list (_install_csrf_reset_patch,
_install_messages_serializer_patch, _install_activerecord_delegation_patch)
are collapsed here.
328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 328 def _install_all_framework_patches _install_rack_request_patch _install_inflector_patch _install_module_introspection_patch _install_parameter_encoding_patch _install_path_registry_patch _install_action_view_resolver_patch _install_action_view_partial_path_patch _install_action_view_field_type_patch _install_action_view_safe_join_patch _install_abstract_controller_patch _install_action_controller_controller_name_patch _install_flash_helpers_patch _install_csrf_reset_patch _install_active_support_error_reporter_patch _install_lookup_context_patch _install_i18n_patch _install_i18n_backend_patch _install_i18n_interpolation_patch _install_template_handlers_patch _install_execution_context_patch _install_request_parameter_parsers_patch _install_query_parser_patch _install_rack_utils_patch _install_log_subscriber_patch _install_local_cache_patch _install_reloader_patch _install_exception_wrapper_patch _install_action_dispatch_routing_patch _install_action_dispatch_mounted_helpers_patch _install_action_dispatch_http_url_patch _install_journey_routes_patch _install_warden_hooks_patch _install_warden_strategies_patch _install_devise_failure_app_patch _install_activerecord_connection_handler_patch _install_activerecord_configurations_patch _install_activerecord_db_config_handlers_patch _install_activerecord_query_transformers_patch _install_activerecord_module_attrs_patch _install_activerecord_deduplicable_patch _install_activerecord_pool_config_patch _install_activerecord_reaper_patch _install_arel_visitor_dispatch_cache_patch _install_arel_bind_block_patch _install_activerecord_quoting_cache_patch _install_activerecord_serialize_cast_value_patch _install_activerecord_delegation_patch _install_activerecord_primary_key_patch _install_activerecord_query_constraints_patch _install_activerecord_relation_delegate_cache_patch _install_activerecord_model_classes_patch _install_active_model_naming_patch _install_active_record_core_patch _install_active_record_inheritance_patch _install_active_record_model_schema_patch _install_activerecord_model_schema_patch _install_openssl_digest_patch _install_caching_key_generator_patch _install_active_model_conversion_patch _install_activerecord_find_by_cache_patch _install_activerecord_migration_patch _install_activerecord_transaction_callbacks_patch _install_activerecord_query_logs_patch _install_kaminari_config_patch _install_propshaft_patch _install_devise_url_helpers_patch _install_devise_authenticatable_patch _install_polymorphic_routes_patch _install_orm_adapter_patch _install_warden_serializer_patch _install_json_encoding_patch _install_active_model_attribute_patch _install_hash_compute_if_absent_patch end |
._install_arel_bind_block_patch ⇒ Object
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 85 def _install_arel_bind_block_patch return if @arel_bind_block_patched @arel_bind_block_patched = true _register_patch :arel_bind_block, "8.1" # PostgreSQL: proc { |i| "$#{i}" } if defined?(::Arel::Visitors::PostgreSQL) ::Arel::Visitors::PostgreSQL.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def bind_block RactorRailsShim::PgBindBlock end RUBY end # ToSql (base): proc { "?" } if defined?(::Arel::Visitors::ToSql) ::Arel::Visitors::ToSql.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def bind_block RactorRailsShim::SqlBindBlock end RUBY end end |
._install_arel_visitor_dispatch_cache_patch ⇒ Object
Patch Arel::Visitors::Visitor.dispatch_cache to route through IES.
dispatch_cache is a class method that lazily initializes a class
instance variable: @dispatch_cache ||= Hash.new { |hash, klass| ... } .compare_by_identity. The cache maps AST node classes to visit method
symbols (e.g. Arel::Nodes::SelectStatement -> :visit_Arel_Nodes_SelectStatement).
It's read on every Arel traversal (every query) and written on cache-miss
(default proc) and on method-not-found fallback (visit() rescue).
The class instance variable @dispatch_cache can't be read or written by
a non-main Ractor (Ractor::IsolationError). The Hash also has a default
Proc (which is intrinsically unshareable), so the value can't be
frozen+shared. This is the third wall a worker hits during the first
query: Post.count -> adapter creation -> arel_visitor ->
ToSql#initialize -> Visitor#initialize -> get_dispatch_cache ->
self.class.dispatch_cache -> @dispatch_cache ||= ....
Fix: route through per-class IES. Each Ractor builds its own mutable
cache (with its own default proc) on first access. The key includes
self.name so each visitor subclass (ToSql, SQLite3, etc.) gets its own
cache — necessary because the method-not-found fallback
(dispatch[object.class] = dispatch[superklass]) resolves differently per
visitor class. The main ractor's existing @dispatch_cache (if any) is
left orphaned; new visitors created after the patch use the IES cache.
String-eval'd (no captured binding), callable from any Ractor.
1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1172 def _install_arel_visitor_dispatch_cache_patch return if @arel_visitor_patched @arel_visitor_patched = true _register_patch :arel_visitor_dispatch_cache, "8.1" return unless defined?(::Arel::Visitors::Visitor) ::Arel::Visitors::Visitor.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def dispatch_cache key = :"ractor_rails_shim_arel_dispatch_\#{name || object_id}" v = ActiveSupport::IsolatedExecutionState[key] return v if v cache = Hash.new do |hash, klass| hash[klass] = :"visit_\#{(klass.name || "").gsub("::", "_")}" end.compare_by_identity ActiveSupport::IsolatedExecutionState[key] = cache cache end RUBY end |
._install_caching_key_generator_patch ⇒ Object
Patch ActiveSupport::CachingKeyGenerator#generate_key. Its @cache_keys
ivar is a Concurrent::Map; make_app_shareable! rewrites Concurrent::Map
ivars into FROZEN Hashes (see make_shareable.rb), so a worker Ractor's
@cache_keys[args.join("|")] ||= ... write raises FrozenError. The cache
is pure memoization keyed by (generator, args), so route it through
IsolatedExecutionState (one mutable cache per Ractor). The inner
OpenSSL::Digest lambda patch.
937 938 939 940 941 942 943 944 945 946 947 948 949 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 937 def _install_caching_key_generator_patch return if @caching_key_generator_patched @caching_key_generator_patched = true _register_patch :caching_key_generator, "8.1" return unless defined?(::ActiveSupport::CachingKeyGenerator) ::ActiveSupport::CachingKeyGenerator.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def generate_key(*args) store = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_caching_key_generator] ||= {}) key = "\#{object_id}|\#{args.join("|")}" store.fetch(key) { store[key] = @key_generator.generate_key(*args) } end RUBY end |
._install_callback_declaration_capture! ⇒ Object
Install an interceptor on ActiveSupport::Callbacks.set_callback that
records, per declaring class, every symbolic :process_action filter
it declares. This must run BEFORE eager load (so declarations are
captured as they happen) — install wires it via
ActiveSupport.on_load(:active_support).
779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 779 def _install_callback_declaration_capture! return if @callback_capture_installed @callback_capture_installed = true # ActiveSupport::Callbacks may not be loaded yet at on_load(:active_support) # time (it's required lazily). Require it so the ClassMethods module with # set_callback exists before we alias it. require "active_support/callbacks" rescue nil mod = (defined?(::ActiveSupport::Callbacks) && ::ActiveSupport::Callbacks.const_defined?(:ClassMethods)) ? ::ActiveSupport::Callbacks::ClassMethods : nil return unless mod && mod.method_defined?(:set_callback) mod.alias_method(:_rrs_orig_set_callback, :set_callback) mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def set_callback(name, *filters, &block) if name == :process_action && filters.length >= 2 && filters[0].is_a?(Symbol) kind = filters[0] filter = filters[1] if filter.is_a?(Symbol) && self.is_a?(::Class) && self.ancestors.include?(::AbstractController::Base) # Rails converts `only:`/`except:` into an ActionFilter object # stored in the callback's `:if`/`:unless` options (NOT a bare # `:only` key). Read the constraint back from the ActionFilter's # @conditional_key (:only/:except) and @actions (a Set of action # name Strings). opts = filters.find { |f| f.is_a?(Hash) } only = nil except = nil if opts [opts[:if], opts[:unless]].each do |arr| next unless arr.is_a?(Array) arr.each do |af| ck = af.instance_variable_get(:@conditional_key) rescue nil acts = af.instance_variable_get(:@actions) rescue nil next unless ck && acts acts = acts.to_a.map(&:to_sym) if acts.respond_to?(:to_a) only = acts if ck == :only except = acts if ck == :except end end end ::RactorRailsShim._record_declared_callback( self.object_id, kind, filter, only, except) end end _rrs_orig_set_callback(name, *filters, &block) end RUBY @callback_capture_installed = true end |
._install_callbacks_nil_safe_patch ⇒ Object
Patch ActiveSupport::Inflector::Inflections to not read @en_instance
/ @instance class ivars from a worker Ractor. The inflections instance
holds rules (Arrays/Hashes of Strings) populated at boot; for a frozen
shared app it's read-only. Workers share the main-ractor's inflections
instance via a shareable fallback (made shareable in place). instance
/ instance_or_fallback are called per-request during routing (camelize).
Patch ActiveSupport::Callbacks#run_callbacks to tolerate a nil
__callbacks (the case in worker Ractors whose class_attribute fallback
couldn't be made shareable because callback chains hold frozen,
self-capturing Procs). For a frozen, read-only shared app the boot-time
callbacks (ExecutionContext push/pop, CurrentAttributes clear) already
ran in the main Ractor at boot; worker Ractors don't need to re-run
them per request (CurrentAttributes/ExecutionContext are thread-local,
hence per-Ractor, and start empty in a fresh worker). When __callbacks
is nil, run_callbacks just yields the block — matching the empty-chain
fast path in the original. Moved here from execution_wrapper.rb (it
patches ActiveSupport::Callbacks, not ExecutionWrapper).
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 46 def _install_callbacks_nil_safe_patch return if @callbacks_nil_safe_patched @callbacks_nil_safe_patched = true _register_patch :callbacks_nil_safe, "8.1" return unless defined?(::ActiveSupport::Callbacks) ::ActiveSupport::Callbacks.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def run_callbacks_with_nil_safe(kind, type = nil) kind = kind.to_sym if RactorRailsShim.thread_mode? && kind == :process_action && defined?(::RactorRailsShim::SHAREABLE_DECLARED_CALLBACKS) # Thread (Puma/Falcon) mode: the eager-load class_attribute leak # corrupts __callbacks, so ALWAYS replay the captured symbolic # filters (ignoring __callbacks entirely). Serving happens in the # main Ractor, where the worker-style empty-__callbacks path # below never triggers. table = ::RactorRailsShim::SHAREABLE_DECLARED_CALLBACKS action = (self.action_name rescue nil) action = action.to_sym if action entries = [] k = self.class while k && k <= ::ActionController::Base rec = table[k.object_id] entries = rec + entries if rec k = k.superclass end unless entries.empty? applies = lambda do |e| next false unless (e[:kind] == :before || e[:kind] == :after) in_only = e[:only].nil? || (action && e[:only].include?(action)) not_except = e[:except].nil? || !(action && e[:except].include?(action)) in_only && not_except end result = nil halted = false entries.each do |e| next unless e[:kind] == :before && applies.call(e) send(e[:filter]) if respond_to?(e[:filter], true) if respond_to?(:performed?) ? performed? : response_body halted = true break end end result = (yield if block_given?) unless halted entries.each do |e| next unless e[:kind] == :after && applies.call(e) send(e[:filter]) if respond_to?(e[:filter], true) end return result end end callbacks = __callbacks[kind] if __callbacks if callbacks.nil? || callbacks.empty? # In a worker Ractor, class_attribute-backed `__callbacks` # falls back to the empty default (see class_attribute.rb / # make_shareable.rb), so controller `before_action` / # `after_action` filters are normally SKIPED. For # `:process_action` we replay the captured SYMBOL filters # (see make_shareable!#_capture_controller_callbacks!) # so actions that depend on a before_action (e.g. `set_post` # loading `@post`) render correctly. Proc/lambda filters # are not captured (self-capturing, unshareable) and are # skipped — a known limitation. if (RactorRailsShim.thread_mode? || !Ractor.main?) && kind.to_sym == :process_action && defined?(::RactorRailsShim::SHAREABLE_DECLARED_CALLBACKS) table = ::RactorRailsShim::SHAREABLE_DECLARED_CALLBACKS action = (self.action_name rescue nil) action = action.to_sym if action # Reconstruct this controller's callback list by walking its # ancestors (superclass-first). Each class recorded ONLY the # filters IT declared (via the set_callback interceptor), which # bypasses the eager-load class_attribute leak that otherwise # pollutes __callbacks. entries = [] k = self.class while k && k <= ::ActionController::Base rec = table[k.object_id] entries = rec + entries if rec k = k.superclass end unless entries.empty? # A captured symbolic filter applies to this action unless # constrained by `only:`/`except:`. `kind` (before/after) # decides whether it wraps the action pre- or post-yield. applies = lambda do |e| next false unless (e[:kind] == :before || e[:kind] == :after) in_only = e[:only].nil? || (action && e[:only].include?(action)) not_except = e[:except].nil? || !(action && e[:except].include?(action)) in_only && not_except end result = nil halted = false entries.each do |e| next unless e[:kind] == :before && applies.call(e) send(e[:filter]) if respond_to?(e[:filter], true) # A before-filter that renders/redirects performs — halt the # chain (as the real ActiveSupport::Callbacks machinery # does) so the action is NOT run. Otherwise an action that # depends on a performed before_action (e.g. Devise's # verify_signed_out_user redirect) would render twice. if respond_to?(:performed?) ? performed? : response_body halted = true break end end result = (yield if block_given?) unless halted entries.each do |e| next unless e[:kind] == :after && applies.call(e) send(e[:filter]) if respond_to?(e[:filter], true) end return result end end yield if block_given? else run_callbacks_without_nil_safe(kind, type) { yield if block_given? } end end alias_method :run_callbacks_without_nil_safe, :run_callbacks alias_method :run_callbacks, :run_callbacks_with_nil_safe RUBY end |
._install_csrf_reset_patch ⇒ Object
In the shared :ractor graph, Devise's engine controllers (e.g.
Devise::SessionsController) end up with a nil csrf_token_storage_strategy
at request time — the value is dropped when make_app_shareable! deep-freezes
the app (RequestForgeryProtection sets it only on ActionController::Base.config,
and the per-controller frozen config copy loses it). A worker then raises
NoMethodError on reset_csrf_token during reset_session (logout /
sign_out). Guard the reset so a missing strategy is a no-op — reset_session
regenerates the session id anyway, discarding the CSRF token.
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 |
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 232 def _install_csrf_reset_patch return if @csrf_reset_patched @csrf_reset_patched = true _register_patch :csrf_reset, "8.1" return unless defined?(::ActionController::RequestForgeryProtection) rfp = ::ActionController::RequestForgeryProtection rfp.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def reset_csrf_token(request) # :doc: request.env.delete(CSRF_TOKEN) strat = csrf_token_storage_strategy strat.reset(request) if strat end RUBY # `csrf_token_storage_strategy` and `forgery_protection_strategy` are # both delegated to the controller `config` (a class_attribute). In the # shared :ractor graph the per-controller frozen `config` copy loses # them, so a worker reads nil and CSRF token handling raises # ("undefined method 'fetch' for nil" / "undefined method 'new' for # nil"). Default to the standard SessionStore / Exception strategies so # token ISSUANCE and VALIDATION work in workers. # # NOTE: prepending to RequestForgeryProtection (a module) would NOT # reach the controllers, because ActionController::Base already # *included* it before this patch runs. Prepend to the BASE CLASS so # every controller (incl. Devise subclasses) picks up the default. # The defaults are referenced via their constant paths (not captured # locals) because `def` bodies do not close over enclosing locals. ::ActionController::Base.prepend(Module.new do def csrf_token_storage_strategy super || ::ActionController::RequestForgeryProtection::SessionStore.new end def forgery_protection_strategy super || ::ActionController::RequestForgeryProtection::ProtectionMethods::Exception end # Delegated to `config` (a class_attribute) which loses its value when # make_app_shareable! deep-freezes the shared graph, so a worker reads # nil. With a nil param key, form_authenticity_param reads params[nil] # and CSRF VALIDATION rejects every POST (even with a valid token), # because the token is carried under the real key (:authenticity_token). # Default to the standard param name so validation can find the token. def request_forgery_protection_token super || :authenticity_token end end) end |
._install_devise_authenticatable_patch ⇒ Object
Devise::Models::Authenticatable::ClassMethods#devise_parameter_filter
memoizes @devise_parameter_filter ||= Devise::ParameterFilter.new(...)
on the MODEL CLASS. A worker Ractor cannot set an instance variable on a
shared class/module, so calling it raises "can not set instance variables
of classes/modules by non-main Ractors". Route the memoized filter through
IsolatedExecutionState (per-Ractor), keyed by the (shared, stable) class
object_id. case_insensitive_keys / strip_whitespace_keys are
class_attribute values the shim already routes through IES, so they read
fine in a worker.
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/ractor_rails_shim/patches/devise.rb', line 85 def _install_devise_authenticatable_patch return if @devise_authenticatable_patched @devise_authenticatable_patched = true _register_patch :devise_authenticatable, "5.0" return unless defined?(::Devise::Models::Authenticatable::ClassMethods) ::Devise::Models::Authenticatable::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def devise_parameter_filter key = :"ractor_rails_shim_devise_param_filter_\#{object_id}" v = ActiveSupport::IsolatedExecutionState[key] return v unless v.nil? f = Devise::ParameterFilter.new(case_insensitive_keys, strip_whitespace_keys) ActiveSupport::IsolatedExecutionState[key] = f f end RUBY end |
._install_devise_failure_app_patch ⇒ Object
Devise::FailureApp.call memoizes its Rack endpoint in a class-level ivar
(@respond ||= action(:respond)). A worker Ractor cannot set instance
variables on a shared class/module, so calling it raises "can not set
instance variables of classes/modules by non-main Ractors". Route the
memoized endpoint through IsolatedExecutionState (per-Ractor), so each
worker builds and caches its own copy without mutating the shared class.
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
# File 'lib/ractor_rails_shim/patches/devise.rb', line 48 def _install_devise_failure_app_patch return if @devise_failure_app_patched @devise_failure_app_patched = true _register_patch :devise_failure_app, "5.0" return unless defined?(::Devise::FailureApp) ::Devise::FailureApp.singleton_class.module_eval <<-'RUBY', __FILE__, __LINE__ + 1 def call(env) respond = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_devise_failure_respond] ||= action(:respond) respond.call(env) end RUBY # Devise::FailureApp#relative_url_root reads `config.action_controller # .try(:relative_url_root)`. `config.action_controller` is a # Rails::Railtie::Configuration whose `relative_url_root` is NOT a real # method, so `try` triggers `method_missing`, which reads the `@@options` # class variable — unreadable from a worker Ractor. `Rails.application # .config.relative_url_root` IS a real method on # Rails::Application::Configuration (no method_missing, no class var), so # use just that; for the common case (no relative root) it returns nil. if defined?(::Devise::FailureApp) ::Devise::FailureApp.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def relative_url_root @relative_url_root ||= Rails.application.config.relative_url_root end RUBY end end |
._install_devise_url_helpers_patch ⇒ Object
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/ractor_rails_shim/patches/devise.rb', line 102 def _install_devise_url_helpers_patch return if @devise_url_helpers_patched @devise_url_helpers_patched = true _register_patch :devise_url_helpers, "5.0" return unless defined?(::Devise::Controllers::UrlHelpers) mod = ::Devise::Controllers::UrlHelpers # Patch Devise.mappings FIRST (before the snapshot below) so every # subsequent read — including the snapshot — does NOT trigger a route # reload. Routes are fully drawn by Rails.application.initialize! before # prepare_for_ractors! runs, so @@mappings is already populated in main; # workers read the shareable snapshot instead. The ORIGINAL # Devise.mappings calls reload_routes_unless_loaded, which during # prepare collapses the RouteSet to a single railtie route (rails/info) # that then gets frozen into the shared app graph — breaking routing for # every worker Ractor. if defined?(::Devise) && ::Devise.respond_to?(:mappings) ::Devise.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def mappings return RactorRailsShim::DEVISE_MAPPINGS if !Ractor.main? && RactorRailsShim.const_defined?(:DEVISE_MAPPINGS) ::Devise.class_variable_get(:@@mappings) end RUBY end # Capture a shareable snapshot of Devise.mappings in MAIN (after routes # are drawn). The real Mapping objects hold an unshareable failure-app # lambda and a default-proc Hash, so we can't share them directly; build # a DeviseMappingSnapshot per scope (see make_shareable.rb). Workers read # this via the patched Devise.mappings reader. if Ractor.main? && defined?(::Devise) begin h = ::Devise.mappings snap = {} h.each { |scope, mapping| snap[scope] = _devise_mapping_snapshot(mapping) } snap = Ractor.make_shareable(snap) rescue nil const_set(:DEVISE_MAPPINGS, snap) if snap rescue nil end end # Redefine each generated helper via string eval (no captured binding). # Replicate the EXACT body of Devise::Controllers::UrlHelpers.generate_helpers! # (lib/devise/controllers/url_helpers.rb): the helper does NOT call the # alias method on the context — it reconstructs the REAL route helper name # from `action` + `scope` + `module_name` (e.g. alias `session_path` -> # real `user_session_path`) and sends THAT to the context. Bake in # `action`/`module_name`/`path_or_url` as literals; `scope` is resolved # per-call from the argument, so interpolate it at runtime (\\#{scope}). routes = ::Devise::URL_HELPERS.slice(*(::Devise.mappings.values.map(&:used_helpers).flatten.uniq)) routes.each do |module_name, actions| [:path, :url].each do |path_or_url| actions.each do |action| action_prefix = action ? "#{action}_" : "" method = :"#{action_prefix}#{module_name}_#{path_or_url}" mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{method}(resource_or_scope, *args) scope = Devise::Mapping.find_scope!(resource_or_scope) router_name = Devise.mappings[scope].router_name context = router_name ? send(router_name) : _devise_route_context context.send("#{action_prefix}\#{scope}_#{module_name}_#{path_or_url}", *args) end RUBY end end end end |
._install_exception_wrapper_patch ⇒ Object
Patch ActionDispatch::ExceptionWrapper instance methods that read @@rescue_responses / @@rescue_templates class variables directly (bypassing the mattr_accessor reader the shim already reroutes through IES). Workers can't read class vars; route through the class method.
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 61 def _install_exception_wrapper_patch return if @exception_wrapper_patched @exception_wrapper_patched = true _register_patch :exception_wrapper, "8.1" return unless defined?(::ActionDispatch::ExceptionWrapper) ::ActionDispatch::ExceptionWrapper.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def rescue_template self.class.rescue_templates[exception_class_name] end def status_code ActionDispatch::Response.rack_status_code(self.class.rescue_responses[exception_class_name]) end def rescue_response? self.class.rescue_responses.key?(exception.class.name) end RUBY # Also patch the class method (status_code_for_exception) that reads # @@rescue_responses directly — called by ActionController::Instrumentation # at request time. Route through the mattr reader (which the shim already # reroutes through IES). ::ActionDispatch::ExceptionWrapper.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def status_code_for_exception(class_name) ActionDispatch::Response.rack_status_code(rescue_responses[class_name]) end RUBY end |
._install_execution_context_patch ⇒ Object
class ivar and nestable are read/written per-request. Route through IES; workers get empty arrays (correct for a read-only shared app where ExecutionContext is per-Ractor and starts empty in a fresh worker).
771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 771 def _install_execution_context_patch return if @exec_context_patched @exec_context_patched = true _register_patch :execution_context, "8.1" return unless defined?(::ActiveSupport::ExecutionContext) ec = ::ActiveSupport::ExecutionContext acb_key = :ractor_rails_shim_exec_context_after_change_callbacks nest_key = :ractor_rails_shim_exec_context_nestable acb_key_str = acb_key.inspect nest_key_str = nest_key.inspect ec.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def after_change_callbacks v = ActiveSupport::IsolatedExecutionState[#{acb_key_str}] return v unless v.nil? if Ractor.main? && instance_variable_defined?(:@after_change_callbacks) v = @after_change_callbacks ActiveSupport::IsolatedExecutionState[#{acb_key_str}] = v v else arr = [] ActiveSupport::IsolatedExecutionState[#{acb_key_str}] = arr arr end end def after_change(&block) after_change_callbacks << block end def nestable v = ActiveSupport::IsolatedExecutionState[#{nest_key_str}] return v unless v.nil? if Ractor.main? && instance_variable_defined?(:@nestable) v = @nestable ActiveSupport::IsolatedExecutionState[#{nest_key_str}] = v v else false end end def nestable=(val) ActiveSupport::IsolatedExecutionState[#{nest_key_str}] = val @nestable = val if Ractor.main? val end RUBY # Rewrite the methods that read @after_change_callbacks directly. ec.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def set(**options) options.symbolize_keys! keys = options.keys store = record.store previous_context = if block_given? keys.zip(store.values_at(*keys)).to_h end store.merge!(options) after_change_callbacks.each(&:call) if block_given? begin yield ensure store.merge!(previous_context) after_change_callbacks.each(&:call) end end end def []=(key, value) record.store[key.to_sym] = value after_change_callbacks.each(&:call) end RUBY end |
._install_flash_helpers_patch ⇒ Object
Patch the flash-type helper methods (notice, alert, ...) defined by
ActionController::Metal::Flash#add_flash_types via
define_method(type) { request.flash[type] }. That block is compiled
in the MAIN Ractor, so calling it from a worker Ractor raises
"defined with an un-shareable Proc in a different Ractor". Redefine each
flash type as a string-eval'd method (no captured binding) so it is
callable from any Ractor. Called at prepare_for_ractors! time, after
the controllers are loaded and the types are known.
289 290 291 292 293 294 295 296 297 298 299 |
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 289 def _install_flash_helpers_patch return if @flash_helpers_patched @flash_helpers_patched = true _register_patch :flash_helpers, "8.1" return unless defined?(::ActionController::Base) types = ::ActionController::Base._flash_types rescue [] types.each do |type| ::ActionController::Base.class_eval "def #{type}; request.flash[#{type.inspect}]; end" ::ActionController::Base.send(:private, type) if ::ActionController::Base.private_method_defined?(type) rescue nil end end |
._install_hash_compute_if_absent_patch ⇒ Object
The shim's make_app_shareable! replaces Concurrent::Map instance variables (which are not Ractor-shareable) with plain Hashes so workers can read them. But Rails code calls Concurrent::Map#compute_if_absent on these caches (e.g. ActiveModel::AttributeMethods' attribute_method_patterns_ cache). Plain Hash lacks that method, so we add a compatible definition. For a MUTABLE cache the shim freezes the replaced Hash (so it is shareable across workers); mutating it would raise FrozenError. So when the receiver is frozen we route the store to per-Ractor IES keyed by the Hash identity and the key — giving each worker its own cache entry without mutating the shared object. Semantics otherwise match Concurrent::Map.
582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 582 def _install_hash_compute_if_absent_patch return @hash_compute_if_absent_patched if defined?(@hash_compute_if_absent_patched) && @hash_compute_if_absent_patched @hash_compute_if_absent_patched = true return if ::Hash.method_defined?(:compute_if_absent) ::Hash.prepend(Module.new do def compute_if_absent(key) if frozen? ies_key = :"rrs_cia_#{object_id}_#{key}" ActiveSupport::IsolatedExecutionState[ies_key] ||= yield(key) elsif key?(key) self[key] else self[key] = yield(key) end end end) end |
._install_i18n_backend_patch ⇒ Object
Patch I18n::Backend::Simple::Implementation#translations and
#store_translations. The original uses a MUTEX-backed Concurrent::Hash
default block (MUTEX is a non-shareable constant on the module), so a
worker Ractor that builds its own (per the patched I18n::Config#backend)
backend and lazy-loads translations hits "can not access non-shareable
objects in constant ...MUTEX". Worker-local backends are single-threaded
(a Ractor serializes its requests), so drop the mutex and use a plain
Hash. Applied at prepare_for_ractors! time (after the i18n backend class
is loaded).
702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 702 def _install_i18n_backend_patch return if @i18n_backend_patched @i18n_backend_patched = true _register_patch :i18n_backend, "8.1" return unless defined?(::I18n::Backend::Simple::Implementation) impl = ::I18n::Backend::Simple::Implementation impl.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def translations(do_init: false) init_translations if do_init && !initialized? @translations ||= {} end def store_translations(locale, data, options = {}) if ::I18n.enforce_available_locales && ::I18n.available_locales_initialized? && !::I18n.locale_available?(locale) return data end locale = locale.to_sym translations[locale] ||= {} data = ::I18n::Utils.deep_symbolize_keys(data) unless options.fetch(:skip_symbolize_keys, false) ::I18n::Utils.deep_merge!(translations[locale], data) end RUBY end |
._install_i18n_interpolation_patch ⇒ Object
Patch I18n.interpolate_hash. It reads INTERPOLATION_PATTERNS_CACHE — a constant Hash with a default proc (unshareable) — to fetch the compiled interpolation Regexp. A worker Ractor cannot read that constant, raising "can not access non-shareable objects in constant I18n::INTERPOLATION_PATTERNS_CACHE by non-main ractor". Route the cache through IsolatedExecutionState so each Ractor compiles its own Regexp once.
733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 733 def _install_i18n_interpolation_patch return if @i18n_interpolation_patched @i18n_interpolation_patched = true _register_patch :i18n_interpolation, "8.1" return unless defined?(::I18n) ::I18n.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def interpolate_hash(string, values) patterns = config.interpolation_patterns cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_interp_cache] ||= {}) pattern = cache[patterns] ||= ::Regexp.union(patterns) interpolated = false interpolated_string = string.gsub(pattern) do |match| interpolated = true if match == '%%' '%' else key = ($1 || $2 || match.tr("%{}", "")).to_sym value = if values.key?(key) values[key] else config.missing_interpolation_argument_handler.call(key, values, string) end value = value.call(values) if value.respond_to?(:call) $3 ? sprintf("%#{$3}", value) : value end end interpolated ? interpolated_string : string end RUBY end |
._install_i18n_patch ⇒ Object
Patch I18n::Config's class-variable-backed accessors (default_locale,
locale, backend, etc.) to not read @@cvars from a worker Ractor. I18n
defines these manually (@@default_locale ||= :en), not via
cattr_accessor, so the shim's mattr rewrite doesn't catch them. The
values are frozen Symbols / shareable config objects; route the
frequently-read ones (default_locale, locale) through IES with the same
default. Read per-request during view lookup (LookupContext details).
329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 329 def _install_i18n_patch return if @i18n_patched @i18n_patched = true _register_patch :i18n, "8.1" return unless defined?(::I18n::Config) cfg = ::I18n::Config dl_key = :ractor_rails_shim_i18n_default_locale l_key = :ractor_rails_shim_i18n_locale av_key = :ractor_rails_shim_i18n_available_locales avs_key = :ractor_rails_shim_i18n_available_locales_set dl_key_str = dl_key.inspect l_key_str = l_key.inspect av_key_str = av_key.inspect avs_key_str = avs_key.inspect cfg.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def default_locale v = ActiveSupport::IsolatedExecutionState[#{dl_key_str}] return v unless v.nil? if Ractor.main? && defined?(@@default_locale) cv = @@default_locale ActiveSupport::IsolatedExecutionState[#{dl_key_str}] = cv return cv end ActiveSupport::IsolatedExecutionState[#{dl_key_str}] = :en :en end def default_locale=(locale) v = locale && locale.to_sym ActiveSupport::IsolatedExecutionState[#{dl_key_str}] = v @@default_locale = v if Ractor.main? v end def locale v = ActiveSupport::IsolatedExecutionState[#{l_key_str}] return v unless v.nil? default_locale end def locale=(locale) v = locale && locale.to_sym ActiveSupport::IsolatedExecutionState[#{l_key_str}] = v v end # available_locales is read per-request during view template lookup # (ActionView::Resolver::PathParser#build_path_regex). The original # reads the @@available_locales class variable, which a worker Ractor # cannot access (Ractor::IsolationError). Route it through IES. In # main we mirror the class var; in a worker we default to [:en] WITHOUT # delegating to backend.available_locales (that path reads the @@backend # / @@load_path class vars, which are also unreadable from a worker). # The template-regex path only needs the list of locale symbols, and # [:en] is the documented I18n default — correct for apps that don't # set config.i18n.available_locales explicitly. def available_locales v = ActiveSupport::IsolatedExecutionState[#{av_key_str}] return v unless v.nil? if Ractor.main? if defined?(@@available_locales) && (cv = @@available_locales) ActiveSupport::IsolatedExecutionState[#{av_key_str}] = cv return cv end al = backend.available_locales al = al.freeze if al.respond_to?(:freeze) && !al.frozen? ActiveSupport::IsolatedExecutionState[#{av_key_str}] = al return al end al = [:en].freeze ActiveSupport::IsolatedExecutionState[#{av_key_str}] = al al end def available_locales=(locales) v = Array(locales).map { |l| l.to_sym } v = nil if v.empty? ActiveSupport::IsolatedExecutionState[#{av_key_str}] = v @@available_locales = v if Ractor.main? v end def available_locales_set v = ActiveSupport::IsolatedExecutionState[#{avs_key_str}] return v unless v.nil? if Ractor.main? && defined?(@@available_locales_set) && (cv = @@available_locales_set) ActiveSupport::IsolatedExecutionState[#{avs_key_str}] = cv return cv end s = available_locales.inject(Set.new) { |set, locale| set << locale.to_s << locale.to_sym } ActiveSupport::IsolatedExecutionState[#{avs_key_str}] = s s end def available_locales_initialized? !!(ActiveSupport::IsolatedExecutionState[#{av_key_str}]) end # enforce_available_locales is read during every I18n.translate (the # Label/tag translation path in views). The original reads the # @@enforce_available_locales class variable, which a worker Ractor # cannot access (Ractor::IsolationError). Route it through IES; in main # we mirror the class var, in a worker we default to `true` (the # documented I18n default). def enforce_available_locales v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_enforce] return v unless v.nil? if Ractor.main? && defined?(@@enforce_available_locales) cv = @@enforce_available_locales ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_enforce] = cv return cv end ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_enforce] = true true end def enforce_available_locales=(val) v = !!val ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_enforce] = v @@enforce_available_locales = v if Ractor.main? v end # backend reads the @@backend class variable (which a worker Ractor # cannot access). The backend holds the loaded translations. Because the # translation data can contain Procs (e.g. `number.nth.ordinals` in # ActiveSupport's en locale), the whole backend cannot be deep-frozen # and shared. Instead, each worker builds its OWN backend instance (of # the same class as the main backend, so fallbacks etc. are preserved) # and lazy-loads translations from the shareable +load_path+ (see the # patched `load_path`/`load_path=`). The worker-local backend is mutable # (its @interpolations Proc is created in the worker, so it's fine). def backend if Ractor.main? @@backend ||= ::I18n::Backend::Simple.new else key = :ractor_rails_shim_i18n_backend b = ActiveSupport::IsolatedExecutionState[key] return b if b cls = (RactorRailsShim.const_defined?(:I18N_BACKEND_CLASS) && RactorRailsShim::I18N_BACKEND_CLASS) || ::I18n::Backend::Simple b = cls.new ActiveSupport::IsolatedExecutionState[key] = b b end end def backend=(value) @@backend = value end # load_path reads the @@load_path class variable (unreadable from a # worker). Capture the (shareable) list of translation file paths in # main; workers reload translations from disk via these paths. def load_path v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_load_path] return v unless v.nil? if Ractor.main? lp = (defined?(@@load_path) && @@load_path) || [] lp = lp.dup.freeze if lp.respond_to?(:freeze) && !lp.frozen? ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_load_path] = lp lp else RactorRailsShim.const_defined?(:I18N_LOAD_PATH) ? RactorRailsShim::I18N_LOAD_PATH : [] end end def load_path=(lp) lp = Array(lp) ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_load_path] = lp @@load_path = lp if Ractor.main? lp end # default_separator / exception_handler / missing_interpolation_argument_handler # / interpolation_patterns each read a @@ class variable unreadable from a # worker. Route them through IES; main mirrors the class var, workers use # the documented default (each default is worker-local and shareable-safe: # a String, a fresh ExceptionHandler, a fresh lambda, or the frozen # DEFAULT_INTERPOLATION_PATTERNS constant). def default_separator v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_sep] return v unless v.nil? if Ractor.main? cv = defined?(@@default_separator) ? @@default_separator : "." ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_sep] = cv cv else "." end end def default_separator=(separator) ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_sep] = separator @@default_separator = separator if Ractor.main? separator end def exception_handler v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_exc] return v unless v.nil? if Ractor.main? cv = defined?(@@exception_handler) ? @@exception_handler : ::I18n::ExceptionHandler.new ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_exc] = cv cv else ::I18n::ExceptionHandler.new end end def exception_handler=(handler) ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_exc] = handler @@exception_handler = handler if Ractor.main? handler end def missing_interpolation_argument_handler v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_miss] return v unless v.nil? if Ractor.main? cv = defined?(@@missing_interpolation_argument_handler) ? @@missing_interpolation_argument_handler : lambda do |missing_key, provided_hash, string| raise ::I18n::MissingInterpolationArgument.new(missing_key, provided_hash, string) end ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_miss] = cv cv else lambda do |missing_key, provided_hash, string| raise ::I18n::MissingInterpolationArgument.new(missing_key, provided_hash, string) end end end def missing_interpolation_argument_handler=(handler) ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_miss] = handler @@missing_interpolation_argument_handler = handler if Ractor.main? handler end def interpolation_patterns v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_ip] return v unless v.nil? if Ractor.main? cv = defined?(@@interpolation_patterns) ? @@interpolation_patterns : ::I18n::DEFAULT_INTERPOLATION_PATTERNS.dup ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_ip] = cv cv else ::I18n::DEFAULT_INTERPOLATION_PATTERNS end end def interpolation_patterns=(patterns) ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_ip] = patterns @@interpolation_patterns = patterns if Ractor.main? patterns end RUBY # Capture the I18n backend class and shareable load paths in MAIN after # the app has initialized. Workers build their own backend instance of # the captured class and reload translations from the captured load paths # (see `I18n::Config#backend` / `#load_path`). Eager-load the backend # class so the constant is globally defined for worker Ractors. if Ractor.main? && defined?(::I18n) begin # Eager-load I18n classes/constants in main so they are globally # defined for worker Ractors (which cannot autoload). ::I18n::Backend::Simple rescue nil ::I18n::ExceptionHandler rescue nil ::I18n::MissingInterpolationArgument rescue nil ::I18n::DEFAULT_INTERPOLATION_PATTERNS rescue nil backend = ::I18n.backend backend.translate(:en, "") rescue nil backend.available_locales rescue nil const_set(:I18N_BACKEND_CLASS, backend.class) unless RactorRailsShim.const_defined?(:I18N_BACKEND_CLASS) raw_lp = (backend.respond_to?(:instance_variable_get) && backend.instance_variable_get(:@load_path)) || (::I18n.respond_to?(:load_path) && ::I18n.load_path) || [] shareable_lp = Ractor.make_shareable(Array(raw_lp).dup) rescue Array(raw_lp).map(&:to_s).freeze const_set(:I18N_LOAD_PATH, shareable_lp) unless RactorRailsShim.const_defined?(:I18N_LOAD_PATH) rescue nil end end # Patch I18n.fallbacks (a singleton method on the I18n module) to not # read the @@fallbacks class variable from a worker Ractor. It already # uses Fiber/Thread-local storage with @@fallbacks as the fallback; # route the @@fallbacks read through IES so workers build their own # I18n::Locale::Fallbacks. Called per-request via LookupContext details. if defined?(::I18n) i18n = ::I18n fb_key = :ractor_rails_shim_i18n_fallbacks fb_key_str = fb_key.inspect i18n.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def fallbacks v = ActiveSupport::IsolatedExecutionState[#{fb_key_str}] return v unless v.nil? if Ractor.main? && defined?(@@fallbacks) cv = @@fallbacks if cv ActiveSupport::IsolatedExecutionState[#{fb_key_str}] = cv return cv end end built = I18n::Locale::Fallbacks.new ActiveSupport::IsolatedExecutionState[#{fb_key_str}] = built built end RUBY # I18n::Locale::Tag.implementation — manual @@implementation ||= Simple. # The value is a module (shareable). Route through IES. if defined?(::I18n::Locale::Tag) tag = ::I18n::Locale::Tag tag_key = :ractor_rails_shim_i18n_tag_implementation tag_key_str = tag_key.inspect tag.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def implementation v = ActiveSupport::IsolatedExecutionState[#{tag_key_str}] return v unless v.nil? if Ractor.main? && defined?(@@implementation) cv = @@implementation ActiveSupport::IsolatedExecutionState[#{tag_key_str}] = cv return cv end ActiveSupport::IsolatedExecutionState[#{tag_key_str}] = I18n::Locale::Tag::Simple I18n::Locale::Tag::Simple end RUBY end # I18n::Base#normalize_key reads the @@normalized_key_cache class # variable (a double-nested Hash with default procs — unshareable, and # unreadable from a worker). It's a pure performance cache, so route it # through IsolatedExecutionState: each Ractor builds its own nested # cache via I18n.new_double_nested_cache and reads/writes it locally. if defined?(::I18n::Base) nk_key = :ractor_rails_shim_i18n_normalized_key_cache nk_key_str = nk_key.inspect ::I18n::Base.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def normalize_key(key, separator) cache = ActiveSupport::IsolatedExecutionState[#{nk_key_str}] cache ||= (ActiveSupport::IsolatedExecutionState[#{nk_key_str}] = ::I18n.new_double_nested_cache) cache[separator][key] ||= case key when Array key.flat_map { |k| normalize_key(k, separator) } else keys = key.to_s.split(separator) keys.delete('') keys.map! do |k| case k when /\A[-+]?([1-9]\d*|0)\z/ # integer k.to_i when 'true' true when 'false' false else k.to_sym end end keys end end RUBY end # I18n.reserved_keys_pattern memoizes its compiled regex in a lazy class # ivar (@reserved_keys_pattern) which a worker Ractor cannot write. # Route the cache through IsolatedExecutionState. if defined?(::I18n) rkp_key = :ractor_rails_shim_i18n_reserved_keys_pattern rkp_key_str = rkp_key.inspect ::I18n.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def reserved_keys_pattern v = ActiveSupport::IsolatedExecutionState[#{rkp_key_str}] return v if v pat = /(?<!%)%\\{(#{::I18n::RESERVED_KEYS.join("|")})\\}/ ActiveSupport::IsolatedExecutionState[#{rkp_key_str}] = pat pat end RUBY end end end |
._install_inflector_patch ⇒ Object
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 201 def _install_inflector_patch return if @inflector_patched @inflector_patched = true _register_patch :inflector, "8.1" return unless defined?(::ActiveSupport::Inflector::Inflections) inf = ::ActiveSupport::Inflector::Inflections en_key = :ractor_rails_shim_inflections_en inst_key = :ractor_rails_shim_inflections_instance en_key_str = en_key.inspect inst_key_str = inst_key.inspect inf.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def instance(locale = :en) if locale == :en v = ActiveSupport::IsolatedExecutionState[#{en_key_str}] return v unless v.nil? if Ractor.main? existing = instance_variable_get(:@__en_instance__) if instance_variable_defined?(:@__en_instance__) ActiveSupport::IsolatedExecutionState[#{en_key_str}] = existing return existing || new.tap { |i| instance_variable_set(:@__en_instance__, i) } end fb = RactorRailsShim::SHAREABLE_FALLBACK[#{en_key_str}] return fb if fb built = new ActiveSupport::IsolatedExecutionState[#{en_key_str}] = built built else h = ActiveSupport::IsolatedExecutionState[#{inst_key_str}] ||= (Ractor.main? ? (instance_variable_defined?(:@__instance__) ? instance_variable_get(:@__instance__) : Concurrent::Map.new) : Concurrent::Map.new) h[locale] ||= new end end def instance_or_fallback(locale) return instance(locale) if locale == :en h = ActiveSupport::IsolatedExecutionState[#{inst_key_str}] if h && h.key?(locale) return h[locale] end if Ractor.main? && instance_variable_defined?(:@__instance__) iv = instance_variable_get(:@__instance__) return iv[locale] if iv && iv.key?(locale) end instance(locale) end RUBY # Register so _build_shareable_fallback! captures the :en inflections # instance (made shareable) for workers. CLASS_ATTRIBUTES << ["ActiveSupport::Inflector::Inflections", :__en_instance__, en_key, nil] # Materialize the :en instance into IES in main so the fallback builder # can read + share it. inf.instance(:en) if Ractor.main? end |
._install_journey_routes_patch ⇒ Object
Make Journey route recognition work under kino :ractor.
The shared app graph (frozen + made shareable by make_app_shareable!)
already carries the routes' ast and the GTG simulator. The simulator,
however, is normally UNshareable because TransitionTable seeds @memos
with a default-Proc Hash (Hash.new { |h,k| h[k] = [] }), and because
its @memos holds the per-route Route objects whose constraint Procs can't
cross Ractor boundaries. The default Proc is the only thing we can fix;
the Route constraint Procs are instead made shareable by make_app_shareable!
when it freezes the whole graph (its proc-replacement pass rewrites them).
So the plan:
1. Patch TransitionTable to use a plain Hash + `add_memo` using `||= []`
(behavior-identical, but shareable once Route memos are frozen).
2. Warm + cache `@ast` / `@simulator` on the live Routes object AFTER
make_app_shareable!'s route precompute (which reloads/resets the
routes) and BEFORE Ractor.make_shareable freezes the graph. Once
frozen into the shared graph, worker Ractors read the cached ivars
via the ORIGINAL Routes#ast/#simulator (no per-worker rebuild — a
rebuild would have to read the frozen Route memos AND reuse several
non-shareable class constants, which is fragile).
We deliberately do NOT override ast/simulator: the original methods read the cached ivars, which is exactly what workers need.
570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 570 def _install_journey_routes_patch return if @journey_routes_patched @journey_routes_patched = true _register_patch :journey_routes, "8.1" return unless defined?(::ActionDispatch::Journey::Routes) # Patch TransitionTable to drop its default-Proc @memos. Must run before # the simulator is warmed (in _warm_journey_routes!, called from # make_app_shareable! after the route precompute). if defined?(::ActionDispatch::Journey::GTG::TransitionTable) tt = ::ActionDispatch::Journey::GTG::TransitionTable tt.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def initialize @stdparam_states = {} @regexp_states = {} @string_states = {} @accepting = {} @memos = {} end def add_memo(idx, memo) (@memos[idx] ||= []) << memo end RUBY end # Make Routes#ast / #simulator tolerant of a FROZEN receiver. The shim # warms + caches these ivars on the live (unfrozen) graph before freezing, # but if the cache is missing on the frozen shared object (e.g. routes # were re-drawn after warming, or warming was skipped), the original # `@simulator ||= build` raise FrozenError in a worker Ractor. When frozen, # build and RETURN the value without memoizing — the build is read-only # over the frozen route nodes, so it's safe and stays worker-local. routes = ::ActionDispatch::Journey::Routes routes.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def ast return @ast if defined?(@ast) && @ast built = ::ActionDispatch::Journey::Nodes::Or.new(anchored_routes.map(&:ast)) frozen? ? built : (@ast ||= built) end def simulator return @simulator if defined?(@simulator) && @simulator gtg = ::ActionDispatch::Journey::GTG::Builder.new(ast).transition_table built = ::ActionDispatch::Journey::GTG::Simulator.new(gtg) frozen? ? built : (@simulator ||= built) end RUBY # Path::Pattern memoizes several ivars via `||=` (@re, @offsets, # @required_names, @optional_names, @requirements_for_missing_keys_check). # On a frozen shared graph the `||=` assignment raises FrozenError in a # worker Ractor. Make them frozen-tolerant: return the cached value if # present, otherwise build and RETURN it without memoizing (the build is # read-only over the frozen ast/requirements). This keeps workers correct # even if warming skipped a pattern. if defined?(::ActionDispatch::Journey::Path::Pattern) ::ActionDispatch::Journey::Path::Pattern.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def required_names return @required_names if defined?(@required_names) && @required_names built = names - optional_names frozen? ? built : (@required_names ||= built) end def optional_names return @optional_names if defined?(@optional_names) && @optional_names built = spec.find_all(&:group?).flat_map { |g| g.find_all(&:symbol?) }.map(&:name).uniq frozen? ? built : (@optional_names ||= built) end def to_regexp return @re if defined?(@re) && @re built = regexp_visitor.new(@separators, @requirements).accept(spec) frozen? ? built : (@re ||= built) end def requirements_for_missing_keys_check return @requirements_for_missing_keys_check if defined?(@requirements_for_missing_keys_check) && @requirements_for_missing_keys_check built = requirements.transform_values { |regex| /\A#\{regex\}\Z/ } frozen? ? built : (@requirements_for_missing_keys_check ||= built) end def offsets return @offsets if defined?(@offsets) && @offsets built = begin offs = [0] spec.find_all(&:symbol?).each do |node| node = node.to_sym if @requirements.key?(node) re = /#\{Regexp.union(@requirements[node])\}|/ offs.push((re.match("").length - 1) + offs.last) else offs << offs.last end end offs end frozen? ? built : (@offsets ||= built) end RUBY end end |
._install_json_encoding_patch ⇒ Object
Patch ActiveSupport::JSON::Encoding. The module memoizes two encoders in
class ivars (@encoder_without_options / @encoder_without_escape) inside
json_encoder=, and exposes json_encoder as a attr_reader (so it too
reads the @json_encoder class ivar). A worker Ractor cannot read any of
these module ivars, raising Ractor::IsolationError ("can not get
unshareable values from instance variables of classes/modules from
non-main Ractors"). Capture the encoder CLASS in main (on assignment) into
a shareable constant, then build a per-Ractor encoder instance via
IsolatedExecutionState instead of reading the module ivars.
1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 1028 def _install_json_encoding_patch return if @json_encoding_patched @json_encoding_patched = true _register_patch :json_encoding, "8.1" return unless defined?(::ActiveSupport::JSON::Encoding) enc = ::ActiveSupport::JSON::Encoding ec_key = :ractor_rails_shim_json_encoder ec_key_str = ec_key.inspect ecn_key = :ractor_rails_shim_json_encoder_no_escape ecn_key_str = ecn_key.inspect enc.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def json_encoder=(encoder) RactorRailsShim.const_set(:JSON_ENCODER_CLASS, encoder) if Ractor.main? && defined?(RactorRailsShim) @json_encoder = encoder if Ractor.main? encoder end def json_encoder RactorRailsShim::JSON_ENCODER_CLASS end def encode_without_options(value) encoder = ActiveSupport::IsolatedExecutionState[#{ec_key_str}] encoder ||= (ActiveSupport::IsolatedExecutionState[#{ec_key_str}] = RactorRailsShim::JSON_ENCODER_CLASS.new) encoder.encode(value) end def encode_without_escape(value) encoder = ActiveSupport::IsolatedExecutionState[#{ecn_key_str}] encoder ||= (ActiveSupport::IsolatedExecutionState[#{ecn_key_str}] = RactorRailsShim::JSON_ENCODER_CLASS.new(escape: false)) encoder.encode(value) end RUBY # The JSON encoding constants (HTML_ENTITIES_REGEX etc.) live on # ActiveSupport::JSON::Encoding but are NOT Ractor-shareable in Ruby 4.0 # (Regexp.union / frozen-string Hash return false for Ractor.shareable?). # Deep-freeze + replace them here (in main, during prepare_for_ractors!) # so worker Ractors can read them when the encoder escapes HTML. Belt and # suspenders alongside the SHAREABLE_CONSTANTS registration. if Ractor.main? %w[ESCAPED_CHARS HTML_ENTITIES_REGEX FULL_ESCAPE_REGEX JS_SEPARATORS_REGEX].each do |name| next unless enc.const_defined?(name, false) v = enc.const_get(name, false) unless Ractor.shareable?(v) begin enc.const_set(name, Ractor.make_shareable(v)) rescue nil end end end end # Make sure the constant exists on RactorRailsShim so worker references # resolve. It is set on the first `json_encoder=` call during init; seed a # default here so even a direct call before init is safe. unless RactorRailsShim.const_defined?(:JSON_ENCODER_CLASS) RactorRailsShim.const_set(:JSON_ENCODER_CLASS, ::ActiveSupport::JSON::Encoding::JSONGemEncoder) end end |
._install_kaminari_config_patch ⇒ Object
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/ractor_rails_shim/patches/kaminari.rb', line 21 def _install_kaminari_config_patch return if @kaminari_patched @kaminari_patched = true _register_patch :kaminari_config, "8.1" return unless defined?(::Kaminari) # In the main ractor, capture the config object and make it shareable. shareable_config = nil if Ractor.main? begin cfg = ::Kaminari.instance_variable_get(:@_config) rescue nil if cfg begin Ractor.make_shareable(cfg) shareable_config = cfg rescue => e # If the config can't be made shareable (unlikely — it's all # integers/symbols/nil), build a fresh one with defaults. shareable_config = Ractor.make_shareable(::Kaminari::Config.new) end else shareable_config = Ractor.make_shareable(::Kaminari::Config.new) end rescue => e # Best-effort end end shareable_config ||= Ractor.make_shareable(::Kaminari::Config.new) rescue nil # Store the shareable config as a constant so workers can read it. if shareable_config verbose, $VERBOSE = $VERBOSE, nil begin const_set(:KAMINARI_SHAREABLE_CONFIG, shareable_config) ensure $VERBOSE = verbose end end k_key = :ractor_rails_shim_kaminari_config k_key_str = k_key.inspect ::Kaminari.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def config v = ActiveSupport::IsolatedExecutionState[#{k_key_str}] return v unless v.nil? if Ractor.main? && instance_variable_defined?(:@_config) @_config else RactorRailsShim::KAMINARI_SHAREABLE_CONFIG end end def config=(val) ActiveSupport::IsolatedExecutionState[#{k_key_str}] = val end RUBY # Register so the shareable fallback builder knows about it. CLASS_ATTRIBUTES << ["Kaminari", :config, k_key, nil] if shareable_config end |
._install_local_cache_patch ⇒ Object
Patch ActiveSupport::Cache::Strategy::LocalCache#local_cache_key. The
original memoizes the key in a @local_cache_key ivar on the store:
`@local_cache_key ||= "...".to_sym`
When the store is part of the frozen, shared Rails.application graph (deep-frozen by make_app_shareable! for kino :ractor mode), a worker Ractor writing that ivar raises FrozenError. The key is a pure function of the store's class + object_id (both stable for the shared object), so compute it deterministically each call — no ivar write. The key still addresses LocalCacheRegistry, which is already Ractor-safe (it uses IsolatedExecutionState), so each Ractor keeps its own local cache.
915 916 917 918 919 920 921 922 923 924 925 926 927 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 915 def _install_local_cache_patch return if @local_cache_patched @local_cache_patched = true _register_patch :local_cache, "8.1" return unless defined?(::ActiveSupport::Cache::Strategy::LocalCache) lc = ::ActiveSupport::Cache::Strategy::LocalCache lc.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def local_cache_key str = "\#{self.class.name.underscore}_local_cache_\#{object_id}".gsub(/[\\/-]/, "_") str.to_sym end RUBY end |
._install_log_subscriber_patch ⇒ Object
Patch ActiveSupport::LogSubscriber.logger — a raw class ivar with lazy init (@logger ||= Rails.logger) that's WRITTEN at request teardown via flush_all!. Workers can't write class ivars → IsolationError. Route through IES; workers get Rails.logger (which the shim already routes through IES) so it resolves to the worker's own per-Ractor logger.
847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 847 def _install_log_subscriber_patch return if @log_subscriber_patched @log_subscriber_patched = true _register_patch :log_subscriber, "8.1" return unless defined?(::ActiveSupport::LogSubscriber) ls = ::ActiveSupport::LogSubscriber key = :ractor_rails_shim_log_subscriber_logger key_str = key.inspect ls.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def logger v = ActiveSupport::IsolatedExecutionState[#{key_str}] return v unless v.nil? if Ractor.main? && instance_variable_defined?(:@logger) @logger elsif defined?(::Rails) && ::Rails.respond_to?(:logger) ::Rails.logger end end def logger=(val) ActiveSupport::IsolatedExecutionState[#{key_str}] = val end RUBY end |
._install_lookup_context_patch ⇒ Object
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 67 def _install_lookup_context_patch return if @lookup_context_patched @lookup_context_patched = true _register_patch :lookup_context, "8.1" return unless defined?(::ActionView::LookupContext) # Force autoload of the nested ActionView::Template constants that the # patched details_cache_key references (Template::Types, # TemplateDetails::Requested). Constants are global, so defining them # here (main ractor) makes them visible to worker ractors, which cannot # autoload. Without this, the first template render in a worker dies on # `NameError: uninitialized constant ActionView::Template::TemplateDetails`. if Ractor.main? && defined?(::ActionView::Template) ::ActionView::Template::Types rescue nil ::ActionView::TemplateDetails rescue nil end lc = ::ActionView::LookupContext key = :ractor_rails_shim_lookup_context_registered_details key_str = key.inspect lc.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def registered_details v = ActiveSupport::IsolatedExecutionState[#{key_str}] return v unless v.nil? if Ractor.main? && instance_variable_defined?(:@registered_details) @registered_details else RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}] || [] end end def registered_details=(val) ActiveSupport::IsolatedExecutionState[#{key_str}] = val @registered_details = val if Ractor.main? val end RUBY CLASS_ATTRIBUTES << ["ActionView::LookupContext", :registered_details, key, []] # Redefine default_#{name} methods. register_detail (line 25 of # lookup_context.rb) defines these via Accessors.define_method(:"default_#{name}", &block) # — a Proc from the main ractor. Calling them from a worker raises # "defined with an un-shareable Proc in a different Ractor". # Trigger: when Accept: */* is sent, request.formats returns [Mime::ALL], # and LookupContext#formats= (the override at line 263) does # `values.concat(default_formats) if values.delete "*/*"` — Mime::ALL # compares == to "*/*", so delete removes it and default_formats is called. # Fix: call each block once in main, make the result shareable, and # redefine the method via string eval (no captured binding). accessors = ::ActionView::LookupContext::Accessors ::ActionView::LookupContext.registered_details.each do |name| block = accessors::DEFAULT_PROCS[name] next unless block begin value = block.call rescue next end begin value = Ractor.make_shareable(value) rescue value = value.dup.freeze rescue value end const_name = "SHIM_DEFAULT_#{name.upcase}_VALUE" verbose, $VERBOSE = $VERBOSE, nil accessors.const_set(const_name, value) $VERBOSE = verbose accessors.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def default_#{name} ::ActionView::LookupContext::Accessors::#{const_name} end RUBY end # Patch ActionView::Rendering::ClassMethods#view_context_class to read # from a shareable registry (populated in main) instead of building via # Class.new{...} blocks (un-shareable Proc from a worker). The built # class is made shareable; workers read it via the registry. if defined?(::ActionView::Rendering::ClassMethods) rcm = ::ActionView::Rendering::ClassMethods # `ActionView::Base.with_empty_template_cache` is patched separately and # EARLY (see _install_with_empty_template_cache_patch, installed via # ActiveSupport.on_load(:action_view) before eager load) because the # framework's original uses `define_method(:compiled_method_container) # { subclass }` — a block/Proc captured in the main Ractor that raises # "defined with an un-shareable Proc in a different Ractor" when a # worker calls it. In production `DetailsKey.view_context_class` calls # with_empty_template_cache during eager load, so the patch MUST be in # place before then. _install_with_empty_template_cache_patch if defined?(::ActionView::Base) # Build the per-controller view_context_class registry in main. We call # the ORIGINAL build_view_context_class directly (bypassing Rails' # inherit_view_context_class? short-circuit, which otherwise makes # subclasses reuse ActionController::Base's class built with a nil # `_routes` and thus NO route url_helpers). `routes` is forced to the # shareable Rails.application.routes when the controller's own `_routes` # is nil, so named helpers (new_post_path, etc.) are always present. if Ractor.main? registry = {} ::AbstractController::Base.descendants.each do |ctrl| begin routes = ctrl.respond_to?(:_routes) ? ctrl._routes : nil routes = ::Rails.application.routes if routes.nil? && ::Rails.respond_to?(:application) && ::Rails.application vcc = rcm.instance_method(:build_view_context_class).bind(ctrl).call( ::ActionView::LookupContext::DetailsKey.view_context_class, ctrl.respond_to?(:supports_path?) ? ctrl.supports_path? : true, routes, ctrl.respond_to?(:_helpers) ? ctrl._helpers : nil ) registry[ctrl] = vcc if vcc rescue => e # skip controllers that can't build (e.g. abstract) end end registry.delete_if { |_, v| v.nil? } registry.freeze begin Ractor.make_shareable(registry) self._view_context_registry = registry rescue => e # If the registry can't be made shareable, leave it — workers fall # back to the empty-cache base. end end rcm.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def view_context_class return @view_context_class if Ractor.main? && instance_variable_defined?(:@view_context_class) && @view_context_class if Ractor.main? @view_context_class ||= build_view_context_class( ActionView::LookupContext::DetailsKey.view_context_class, supports_path?, _routes, _helpers ) return @view_context_class end # Worker Ractor. The frozen shared controller class carries a # memoized @view_context_class built in main (in the registry) with # its proper route url_helpers + controller helpers. Look it up by # controller class; fall back to the shareable fallback class (built # in main with Rails.application.routes) for any controller not # present in the registry at prepare time. vcc = RactorRailsShim._view_context_registry[self] return vcc if vcc RactorRailsShim._view_context_fallback end # `inherit_view_context_class?` (action_view/rendering.rb:52) compares # `superclass._helpers` — and `_helpers` is defined via a block # (redefine_singleton_method) that cannot run in a worker Ractor # ("defined with an un-shareable Proc in a different Ractor"). Return # false so each controller builds its own view_context_class (the # normal Rails behaviour whenever _routes/_helpers differ), avoiding # the `_helpers` comparison entirely. Behaviour is identical in the # main Ractor (the inherited class would be equivalent). def inherit_view_context_class? false end RUBY end vcc_key = :ractor_rails_shim_lookup_context_view_context_class vcc_key_str = vcc_key.inspect CLASS_ATTRIBUTES << ["ActionView::LookupContext::DetailsKey", :view_context_class, vcc_key, nil] # Build it now in main and stash in IES so the fallback builder picks it up. if Ractor.main? && defined?(::ActionView::Base) ActiveSupport::IsolatedExecutionState[vcc_key] = ::ActionView::LookupContext::DetailsKey.view_context_class # Shareable fallback view_context_class for any controller not present in # the registry at prepare time. Subclasses ActionView::Base, so it # inherits the per-class compiled_method_container (self.class) and the # route url_helpers. build_view_context_class is a ClassMethods method on # controllers, so invoke it via the unbound method bound to # ActionController::Base. fallback = rcm.instance_method(:build_view_context_class).bind(::ActionController::Base).call( ::ActionView::LookupContext::DetailsKey.view_context_class, true, (defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application) ? ::Rails.application.routes : nil, nil ) Ractor.make_shareable(fallback) rescue nil self._view_context_fallback = fallback end dk = ::ActionView::LookupContext::DetailsKey dk_key = :ractor_rails_shim_lookup_context_details_keys dc_key = :ractor_rails_shim_lookup_context_digest_cache dk_key_str = dk_key.inspect dc_key_str = dc_key.inspect vcc_key_str2 = vcc_key.inspect dk.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def view_context_class v = ActiveSupport::IsolatedExecutionState[#{vcc_key_str2}] return v unless v.nil? if Ractor.main? && instance_variable_defined?(:@view_context_class) v = @view_context_class ActiveSupport::IsolatedExecutionState[#{vcc_key_str2}] = v v else RactorRailsShim::SHAREABLE_FALLBACK[#{vcc_key_str2}] end end def details_keys v = ActiveSupport::IsolatedExecutionState[#{dk_key_str}] return v if v if Ractor.main? && instance_variable_defined?(:@details_keys) v = @details_keys else v = Concurrent::Map.new end ActiveSupport::IsolatedExecutionState[#{dk_key_str}] = v v end def digest_cache(details) dc = (ActiveSupport::IsolatedExecutionState[#{dc_key_str}] ||= Concurrent::Map.new) dc[details_cache_key(details)] ||= Concurrent::Map.new end def details_cache_key(details) details_keys.fetch(details) do if formats = details[:formats] unless ::ActionView::Template::Types.valid_symbols?(formats) details = details.dup details[:formats] &= ::ActionView::Template::Types.symbols end end details_keys[details] ||= ::ActionView::TemplateDetails::Requested.new(**details) end end RUBY end |
._install_messages_serializer_patch ⇒ Object
Patch ActiveSupport::Messages::SerializerWithFallback. Its SERIALIZERS
constant is a Hash of serializer modules — but the Hash itself is not
Ractor-shareable, so a worker Ractor reading it raises "can not access
non-shareable objects in constant ...SERIALIZERS". The individual
serializer modules ARE shareable, so route the lookup through
IsolatedExecutionState (a per-Ractor cache of the same module
references, which workers can read). .load resolves the fallback
serializer the same way.
959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 959 def return if @messages_serializer_patched @messages_serializer_patched = true _register_patch :messages_serializer, "8.1" return unless defined?(::ActiveSupport::Messages::SerializerWithFallback) swf = ::ActiveSupport::Messages::SerializerWithFallback swf.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def serializer_for(format) if Ractor.main? SERIALIZERS.fetch(format) else (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_serializers] ||= { marshal: ::ActiveSupport::Messages::SerializerWithFallback::MarshalWithFallback, json: ::ActiveSupport::Messages::SerializerWithFallback::JsonWithFallback, json_allow_marshal: ::ActiveSupport::Messages::SerializerWithFallback::JsonWithFallbackAllowMarshal, message_pack: ::ActiveSupport::Messages::SerializerWithFallback::MessagePackWithFallback, message_pack_allow_marshal: ::ActiveSupport::Messages::SerializerWithFallback::MessagePackWithFallbackAllowMarshal, })[format] end end def [](format) if format.to_s.include?("message_pack") && !defined?(::ActiveSupport::MessagePack) require "active_support/message_pack" end serializer_for(format) end RUBY swf.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def load(dumped) format = detect_format(dumped) if format == self.format _load(dumped) elsif format && fallback?(format) payload = { serializer: self.format, fallback: format, serialized: dumped } ActiveSupport::Notifications.instrument("message_serializer_fallback.active_support", payload) do payload[:deserialized] = serializer_for(format)._load(dumped) end else raise "Unsupported serialization format" end end RUBY # MessagePackWithFallback#available? lazily memoizes `@available` directly # on the module. When a worker Ractor first deserializes a cookie, # SerializerWithFallback#load -> detect_format -> MessagePackWithFallback # .dumped? -> available? tries to SET that ivar, raising # Ractor::IsolationError: can not set instance variables of # classes/modules by non-main Ractors # Replace the ivar memoization with a pure constant check. The module is # shareable and ActiveSupport::MessagePack resolves to a shareable class, # so this is safe from any Ractor. ::ActiveSupport::Messages::SerializerWithFallback::MessagePackWithFallback.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def available? defined?(::ActiveSupport::MessagePack) end RUBY end |
._install_module_introspection_patch ⇒ Object
Patch Module#module_parent_name so a worker Ractor does not write the
@parent_name class ivar on a shared (non-frozen) module. The default
memoizes @parent_name ||= ... on first use; when that first use happens
in a worker it writes a class ivar on a shared module, which raises
Ractor::IsolationError ("can not set instance variables of
classes/modules by non-main Ractors"). Route the per-worker cache through
IsolatedExecutionState (keyed by module object_id); main keeps the
original class-ivar behavior.
261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 261 def _install_module_introspection_patch return if @module_introspection_patched @module_introspection_patched = true _register_patch :module_introspection, "8.1" return unless defined?(::Module) ::Module.module_eval do def module_parent_name if defined?(@parent_name) @parent_name else name = self.name return if name.nil? parent_name = name =~ /::[^:]+\z/ ? -$` : nil if Ractor.main? @parent_name = parent_name unless frozen? else store = (ActiveSupport::IsolatedExecutionState[:rrs_module_parent_names] ||= {}) store[object_id] ||= parent_name end parent_name end end end end |
._install_notifications_notifier_patch ⇒ Object
Patch ActiveSupport::Notifications.notifier to not read the @notifier
class ivar from a worker Ractor. The original is attr_accessor :notifier with @notifier = Fanout.new set at module load — a raw
class ivar holding a Fanout (which has a Mutex + subscriber Procs,
both unshareable). Workers get their own per-Ractor Fanout (no
subscribers — instrumentation is a no-op in workers, which is correct
for a read-only shared app where log subscribers already ran in main).
notifier is read by instrumenter (per-request via Rails::Rack::Logger).
- Moved here from execution_wrapper.rb (it patches ActiveSupport
Notifications, not ExecutionWrapper).
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 178 def _install_notifications_notifier_patch return if @notifications_notifier_patched @notifications_notifier_patched = true _register_patch :notifications_notifier, "8.1" return unless defined?(::ActiveSupport::Notifications) notif = ::ActiveSupport::Notifications nkey = :ractor_rails_shim_notifications_notifier nkey_str = nkey.inspect notif.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def notifier v = ActiveSupport::IsolatedExecutionState[#{nkey_str}] return v unless v.nil? if Ractor.main? && instance_variable_defined?(:@notifier) @notifier else built = ActiveSupport::Notifications::Fanout.new ActiveSupport::IsolatedExecutionState[#{nkey_str}] = built built end end RUBY end |
._install_openssl_digest_patch ⇒ Object
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
# File 'lib/ractor_rails_shim/patches/openssl.rb', line 29 def _install_openssl_digest_patch return if @openssl_digest_patched @openssl_digest_patched = true _register_patch :openssl_digest, "8.1" return unless defined?(::OpenSSL::Digest) algos = %w(MD4 MD5 RIPEMD160 SHA1 SHA224 SHA256 SHA384 SHA512) algos.each do |name| klass_name = name.tr("-", "_") next unless ::OpenSSL::Digest.const_defined?(klass_name) klass = ::OpenSSL::Digest.const_get(klass_name) next unless klass.is_a?(::Class) # Replace the lambda-based `initialize` (super(name, data)) with a # string-eval'd method that holds no captured Proc. klass.class_eval "def initialize(data = nil); super(#{name.inspect}, data); end" # Replace the singleton `digest`/`hexdigest` blocks the same way. klass.singleton_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def digest(data) new.digest(data) end def hexdigest(data) new.hexdigest(data) end RUBY end end |
._install_orm_adapter_patch ⇒ Object
14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
# File 'lib/ractor_rails_shim/patches/orm_adapter.rb', line 14 def _install_orm_adapter_patch return if @orm_adapter_patched @orm_adapter_patched = true _register_patch :orm_adapter, "8.1" return unless defined?(::OrmAdapter::ToAdapter) ::OrmAdapter::ToAdapter.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def to_adapter key = :"ractor_rails_shim_orm_adapter_\#{object_id}" v = ActiveSupport::IsolatedExecutionState[key] return v unless v.nil? adapter = self::OrmAdapter.new(self) ActiveSupport::IsolatedExecutionState[key] = adapter adapter end RUBY end |
._install_parameter_encoding_patch ⇒ Object
Patch ActionController::ParameterEncoding::ClassMethods#action_encoding_template
to not read @_parameter_encodings (a raw class ivar) from a worker
Ractor. The default is an empty-ish Hash; for a frozen shared app workers
get an empty frozen Hash (no per-action param encodings — correct for
apps that don't declare parameter_encoding, e.g. the health controller).
25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 25 def _install_parameter_encoding_patch return if @param_encoding_patched @param_encoding_patched = true _register_patch :parameter_encoding, "8.1" return unless defined?(::ActionController::ParameterEncoding) pe = ::ActionController::ParameterEncoding::ClassMethods pe.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def action_encoding_template(action) enc = if Ractor.main? instance_variable_defined?(:@_parameter_encodings) ? @_parameter_encodings : nil else ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_param_encodings] end if enc && enc.has_key?(action.to_s) enc[action.to_s] end end RUBY end |
._install_path_registry_patch ⇒ Object
Patch ActionView::PathRegistry to not read its raw class ivars
(@view_paths_by_class, @file_system_resolvers) from a worker Ractor.
These are populated at boot (view paths registered by the app). For a
frozen shared app they're read-only; workers read them via the shareable
fallback (built from main's values, made shareable). get_view_paths is
called per-request during view lookup; all_file_system_resolvers is
called by the exception backtrace builder.
398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 398 def _install_path_registry_patch return if @path_registry_patched @path_registry_patched = true _register_patch :path_registry, "8.1" return unless defined?(::ActionView::PathRegistry) pr = ::ActionView::PathRegistry vpc_key = :ractor_rails_shim_path_registry_view_paths_by_class fsr_key = :ractor_rails_shim_path_registry_file_system_resolvers vpc_key_str = vpc_key.inspect fsr_key_str = fsr_key.inspect pr.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def get_view_paths(klass) h = ActiveSupport::IsolatedExecutionState[#{vpc_key_str}] h = (Ractor.main? ? (instance_variable_defined?(:@view_paths_by_class) ? instance_variable_get(:@view_paths_by_class) : {}) : RactorRailsShim::SHAREABLE_FALLBACK[#{vpc_key_str}]) if h.nil? h[klass] || get_view_paths(klass.superclass) end def set_view_paths(klass, paths) h = ActiveSupport::IsolatedExecutionState[#{vpc_key_str}] ||= (Ractor.main? ? (instance_variable_defined?(:@view_paths_by_class) ? instance_variable_get(:@view_paths_by_class) : {}) : {}) h[klass] = paths instance_variable_set(:@view_paths_by_class, h) if Ractor.main? end def all_file_system_resolvers h = ActiveSupport::IsolatedExecutionState[#{fsr_key_str}] h = (Ractor.main? ? (instance_variable_defined?(:@file_system_resolvers) ? instance_variable_get(:@file_system_resolvers) : {}) : RactorRailsShim::SHAREABLE_FALLBACK[#{fsr_key_str}]) if h.nil? h.values end RUBY # Register so the fallback builder captures + shares these. CLASS_ATTRIBUTES << ["ActionView::PathRegistry", :view_paths_by_class, vpc_key, {}] CLASS_ATTRIBUTES << ["ActionView::PathRegistry", :file_system_resolvers, fsr_key, {}] end |
._install_polymorphic_routes_patch ⇒ Object
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/ractor_rails_shim/patches/polymorphic_routes.rb', line 21 def _install_polymorphic_routes_patch return if @polymorphic_routes_patched @polymorphic_routes_patched = true _register_patch :polymorphic_routes, "8.1" return unless defined?(::ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder) hmb = ::ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder hmb.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def get(action, type) type = type.to_sym build action, type end def url build nil, "url" end def path build nil, "path" end RUBY hmb.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def handle_model_call(target, record) if mapping = polymorphic_mapping(target, record) rescue nil mapping.call(target, [record], suffix == "path") else method, args = handle_model(record) target.public_send(method, *args) end end RUBY # The module-level `polymorphic_path` / `polymorphic_url` (called by # `form_with` when it infers the URL from a model) invoke # `mapping.call` directly. A custom `resolve` mapping Proc is built in # the main Ractor and is un-shareable, so calling it from a worker # Ractor raises "defined with an un-shareable Proc in a different # Ractor". Rescue that and fall through to the (worker-safe) # HelperMethodBuilder path, which derives the route from the record's # model name — the same fallback the original code uses when no mapping # is registered at all. pm = ::ActionDispatch::Routing::PolymorphicRoutes pm.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def polymorphic_path(record_or_hash_or_array, options = {}) if ::Hash === record_or_hash_or_array options = record_or_hash_or_array.merge(options) record = options.delete :id return polymorphic_path record, options end if mapping = (polymorphic_mapping(record_or_hash_or_array) rescue nil) begin return mapping.call(self, [record_or_hash_or_array, options], true) rescue ::Ractor::Error end end opts = options.dup action = opts.delete :action type = :path HelperMethodBuilder.polymorphic_method self, record_or_hash_or_array, action, type, opts end def polymorphic_url(record_or_hash_or_array, options = {}) if ::Hash === record_or_hash_or_array options = record_or_hash_or_array.merge(options) record = options.delete :id return polymorphic_url record, options end if mapping = (polymorphic_mapping(record_or_hash_or_array) rescue nil) begin return mapping.call(self, [record_or_hash_or_array, options], false) rescue ::Ractor::Error end end opts = options.dup action = opts.delete :action type = opts.delete(:routing_type) || :url HelperMethodBuilder.polymorphic_method self, record_or_hash_or_array, action, type, opts end RUBY end |
._install_propshaft_patch ⇒ Object
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
# File 'lib/ractor_rails_shim/patches/propshaft.rb', line 27 def _install_propshaft_patch return if @propshaft_patched @propshaft_patched = true _register_patch :propshaft, "1.3" return unless defined?(::Propshaft::LoadPath) lp = ::Propshaft::LoadPath lp.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def asset_paths_by_type(content_type) cache = (ActiveSupport::IsolatedExecutionState[:"ractor_rails_shim_propshaft_type_\#{object_id}"] ||= {}) if cache.key?(content_type) cache[content_type] else cache[content_type] = extract_logical_paths_from(assets.select { |a| a.content_type == ::Mime::EXTENSION_LOOKUP[content_type] }) end end def asset_paths_by_glob(glob) cache = (ActiveSupport::IsolatedExecutionState[:"ractor_rails_shim_propshaft_glob_\#{object_id}"] ||= {}) if cache.key?(glob) cache[glob] else cache[glob] = extract_logical_paths_from(assets.select { |a| a.path.fnmatch?(glob) }) end end RUBY if lp.method_defined?(:asset_paths_by_path) lp.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def asset_paths_by_path(path) cache = (ActiveSupport::IsolatedExecutionState[:"ractor_rails_shim_propshaft_path_\#{object_id}"] ||= {}) if cache.key?(path) cache[path] else cache[path] = extract_logical_paths_from(assets.select { |a| a.path.fnmatch?(path) }) end end RUBY end end |
._install_query_parser_patch ⇒ Object
ActionDispatch::QueryParser.each_pair returns enum_for(:each_pair, s, separator) when called without a block. The Enumerator wraps a Proc that
was compiled in the main Ractor, so when a worker Ractor iterates it
(pairs.each inside ParamBuilder#from_pairs) Ruby raises "defined with an
un-shareable Proc in a different Ractor". Redefine it to materialize into a
plain (shareable) frozen Array using a block-free loop, so worker Ractors
can parse form/query pairs without crossing Ractor boundaries.
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 124 def _install_query_parser_patch return if @query_parser_patched @query_parser_patched = true return unless defined?(::ActionDispatch::QueryParser) ::ActionDispatch::QueryParser.singleton_class.module_eval <<-'RUBY', __FILE__, __LINE__ + 1 def each_pair(s, separator = nil) return _materialized_pairs(s, separator) unless block_given? s ||= "" splitter = if separator ::ActionDispatch::QueryParser::COMMON_SEP[separator] || /[#{separator}] */n else ::ActionDispatch::QueryParser::DEFAULT_SEP end s.split(splitter).each do |part| next if part.empty? k, v = part.split("=", 2) k = URI.decode_www_form_component(k) v &&= URI.decode_www_form_component(v) yield k, v end nil end def _materialized_pairs(s, separator) s ||= "" splitter = if separator ::ActionDispatch::QueryParser::COMMON_SEP[separator] || /[#{separator}] */n else ::ActionDispatch::QueryParser::DEFAULT_SEP end parts = s.split(splitter) result = [] i = 0 while i < parts.length part = parts[i] i += 1 if part.empty? next end kv = part.split("=", 2) k = URI.decode_www_form_component(kv[0]) v = kv[1] && URI.decode_www_form_component(kv[1]) result << [k, v] end result.freeze end RUBY end |
._install_rack_request_patch ⇒ Object
Patch Rack::Request's class-level attr_accessors (forwarded_priority, x_forwarded_proto_priority) to not read @ivars from a worker Ractor. The values are frozen-Symbol Arrays (shareable); route the cache through IES with the same default in workers. Read per-request via ActionDispatch::RemoteIp. Applied at prepare_for_ractors! time (after Rails boots, so Rack is loaded).
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/ractor_rails_shim/patches/rack.rb', line 46 def _install_rack_request_patch return if @rack_request_patched @rack_request_patched = true _register_patch :rack_request, "8.1" return unless defined?(::Rack::Request) req = ::Rack::Request fp_key = :ractor_rails_shim_rack_forwarded_priority xp_key = :ractor_rails_shim_rack_x_forwarded_proto_priority fp_key_str = fp_key.inspect xp_key_str = xp_key.inspect req.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def forwarded_priority v = ActiveSupport::IsolatedExecutionState[#{fp_key_str}] return v unless v.nil? if Ractor.main? && instance_variable_defined?(:@forwarded_priority) @forwarded_priority else [:forwarded, :x_forwarded] end end def forwarded_priority=(val) ActiveSupport::IsolatedExecutionState[#{fp_key_str}] = val @forwarded_priority = val if Ractor.main? val end def x_forwarded_proto_priority v = ActiveSupport::IsolatedExecutionState[#{xp_key_str}] return v unless v.nil? if Ractor.main? && instance_variable_defined?(:@x_forwarded_proto_priority) @x_forwarded_proto_priority else [:proto, :scheme] end end def x_forwarded_proto_priority=(val) ActiveSupport::IsolatedExecutionState[#{xp_key_str}] = val @x_forwarded_proto_priority = val if Ractor.main? val end RUBY end |
._install_rack_utils_patch ⇒ Object
Patch Rack::Utils singleton attr_accessors (default_query_parser,
multipart_total_part_limit, multipart_file_limit) to not read @ivars
from a worker Ractor. The values are shareable once frozen (QueryParser,
Integers). Route through IES; workers read the shareable fallback.
default_query_parser is read per-request during POST parsing.
93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/ractor_rails_shim/patches/rack.rb', line 93 def _install_rack_utils_patch return if @rack_utils_patched @rack_utils_patched = true _register_patch :rack_utils, "8.1" return unless defined?(::Rack::Utils) u = ::Rack::Utils dqp_key = :ractor_rails_shim_rack_utils_default_query_parser mtp_key = :ractor_rails_shim_rack_utils_multipart_total_part_limit mfl_key = :ractor_rails_shim_rack_utils_multipart_file_limit dqp_key_str = dqp_key.inspect mtp_key_str = mtp_key.inspect mfl_key_str = mfl_key.inspect u.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def default_query_parser v = ActiveSupport::IsolatedExecutionState[#{dqp_key_str}] return v unless v.nil? if Ractor.main? && instance_variable_defined?(:@default_query_parser) v = @default_query_parser ActiveSupport::IsolatedExecutionState[#{dqp_key_str}] = v v else RactorRailsShim::SHAREABLE_FALLBACK[#{dqp_key_str}] || ::Rack::QueryParser::QueryParser.make_default(32) end end def multipart_total_part_limit v = ActiveSupport::IsolatedExecutionState[#{mtp_key_str}] return v unless v.nil? if Ractor.main? && instance_variable_defined?(:@multipart_total_part_limit) v = @multipart_total_part_limit ActiveSupport::IsolatedExecutionState[#{mtp_key_str}] = v v else RactorRailsShim::SHAREABLE_FALLBACK[#{mtp_key_str}] || 128 end end def multipart_file_limit v = ActiveSupport::IsolatedExecutionState[#{mfl_key_str}] return v unless v.nil? if Ractor.main? && instance_variable_defined?(:@multipart_file_limit) v = @multipart_file_limit ActiveSupport::IsolatedExecutionState[#{mfl_key_str}] = v v else RactorRailsShim::SHAREABLE_FALLBACK[#{mfl_key_str}] || 64 end end RUBY CLASS_ATTRIBUTES << ["Rack::Utils", :default_query_parser, dqp_key, nil] CLASS_ATTRIBUTES << ["Rack::Utils", :multipart_total_part_limit, mtp_key, nil] CLASS_ATTRIBUTES << ["Rack::Utils", :multipart_file_limit, mfl_key, nil] end |
._install_reloader_patch ⇒ Object
Patch ActiveSupport::Reloader#check! / #reloaded!. These are CLASS
- methods that memoize
@should_reloadin a class ivar. ActionDispatch
Executor#call runs Reloader.run! -> check! on EVERY request, so a
worker Ractor writing that class ivar raises Ractor::IsolationError
("can not set instance variables of classes/modules by non-main
Ractors"). Route the flag through IsolatedExecutionState so each Ractor
has its own. With reloading disabled (config.enable_reloading = false,
the right setting for a frozen, shared kino :ractor graph) check.call is
lambda { false }, so workers compute false (no reload) — but the write
must still be Ractor-safe.
882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 882 def _install_reloader_patch return if @reloader_patched @reloader_patched = true _register_patch :reloader, "8.1" return unless defined?(::ActiveSupport::Reloader) rl = ::ActiveSupport::Reloader key = :ractor_rails_shim_reloader_should_reload key_str = key.inspect rl.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def check! v = ActiveSupport::IsolatedExecutionState[#{key_str}] return v unless v.nil? result = check.call ActiveSupport::IsolatedExecutionState[#{key_str}] = result result end def reloaded! ActiveSupport::IsolatedExecutionState[#{key_str}] = false end RUBY end |
._install_request_parameter_parsers_patch ⇒ Object
Patch ActionDispatch::Request.parameter_parsers (singleton attr_reader backed by @parameter_parsers) to not read the class ivar from a worker Ractor. The value is a Hash of MIME-type → parser (lambdas). Route through IES; workers read the shareable fallback (the boot-time parsers, made shareable). Read per-request during parameter parsing.
93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 93 def _install_request_parameter_parsers_patch return if @request_param_parsers_patched @request_param_parsers_patched = true _register_patch :request_parameter_parsers, "8.1" return unless defined?(::ActionDispatch::Request) req = ::ActionDispatch::Request pp_key = :ractor_rails_shim_request_parameter_parsers pp_key_str = pp_key.inspect req.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def parameter_parsers v = ActiveSupport::IsolatedExecutionState[#{pp_key_str}] return v unless v.nil? if Ractor.main? && instance_variable_defined?(:@parameter_parsers) v = @parameter_parsers ActiveSupport::IsolatedExecutionState[#{pp_key_str}] = v v else RactorRailsShim::SHAREABLE_FALLBACK[#{pp_key_str}] || ActionDispatch::Request::DEFAULT_PARSERS end end RUBY CLASS_ATTRIBUTES << ["ActionDispatch::Request", :parameter_parsers, pp_key, nil] end |
._install_template_handlers_patch ⇒ Object
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 298 def _install_template_handlers_patch return if @template_handlers_patched @template_handlers_patched = true _register_patch :template_handlers, "8.1" return unless defined?(::ActionView::Template::Handlers) # Eager-load the handler classes in main so workers don't need to # autoload them (workers can't autoload). if Ractor.main? ::ActionView::Template::Handlers::Raw rescue nil ::ActionView::Template::Handlers::ERB rescue nil ::ActionView::Template::Handlers::Html rescue nil ::ActionView::Template::Handlers::Builder rescue nil end h = ::ActionView::Template::Handlers th_key = :ractor_rails_shim_av_template_handlers dth_key = :ractor_rails_shim_av_default_template_handlers th_key_str = th_key.inspect dth_key_str = dth_key.inspect # The handler registry lives in class variables (@@template_handlers, # @@default_template_handlers, @@template_extensions) whose values are # mutable Hashes holding handler instances — and an unshareable `:ruby` # lambda. A worker Ractor cannot read these class vars. Route the # registry through IsolatedExecutionState: each Ractor builds its own # handler map (the defaults are deterministic), and in main we seed from # the live class var (capturing any custom handlers gems registered at # boot). The `:ruby` lambda makes the map unshareable, so the old # SHAREABLE_FALLBACK approach (which skips unshareable values) left # workers with an empty map. These are instance methods (Handlers is # extended into ActionView::Template, and the render path calls them on # the Template instance), so they must be defined on the module itself, # not just the singleton class. h.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def self._ractor_rails_shim_handlers map = ActiveSupport::IsolatedExecutionState[#{th_key_str}] return map unless map.nil? if Ractor.main? cv = class_variable_get(:@@template_handlers) rescue nil cv = nil if cv && cv.empty? if cv ActiveSupport::IsolatedExecutionState[#{th_key_str}] = cv return cv end end built = { raw: ::ActionView::Template::Handlers::Raw.new, erb: ::ActionView::Template::Handlers::ERB.new, html: ::ActionView::Template::Handlers::Html.new, builder: ::ActionView::Template::Handlers::Builder.new, ruby: ->(_, source) { source }, } ActiveSupport::IsolatedExecutionState[#{th_key_str}] = built built end def self._ractor_rails_shim_persist(map) ActiveSupport::IsolatedExecutionState[#{th_key_str}] = map class_variable_set(:@@template_handlers, map) if Ractor.main? end def self.extensions self._ractor_rails_shim_handlers.keys end def registered_template_handler(extension) extension && ::ActionView::Template::Handlers._ractor_rails_shim_handlers[extension.to_sym] end def handler_for_extension(extension) registered_template_handler(extension) || ::ActionView::Template::Handlers::ERB.new end def template_handler_extensions ::ActionView::Template::Handlers._ractor_rails_shim_handlers.keys.map(&:to_s).sort end def register_template_handler(*extensions, handler) map = ::ActionView::Template::Handlers._ractor_rails_shim_handlers.dup extensions.each { |ext| map[ext.to_sym] = handler } ::ActionView::Template::Handlers._ractor_rails_shim_persist(map) end def unregister_template_handler(*extensions) map = ::ActionView::Template::Handlers._ractor_rails_shim_handlers.dup extensions.each { |ext| map.delete(ext.to_sym) } ::ActionView::Template::Handlers._ractor_rails_shim_persist(map) end def register_default_template_handler(extension, klass) register_template_handler(extension, klass) end RUBY end |
._install_warden_hooks_patch ⇒ Object
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
# File 'lib/ractor_rails_shim/patches/warden.rb', line 27 def _install_warden_hooks_patch return if @warden_patched @warden_patched = true _register_patch :warden_hooks, "8.1" return unless defined?(::Warden::Hooks) ::Warden::Hooks.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def _after_set_user if Ractor.main? && instance_variable_defined?(:@_after_set_user) @_after_set_user else [] end end def _before_failure if Ractor.main? && instance_variable_defined?(:@_before_failure) @_before_failure else [] end end def _after_failed_fetch if Ractor.main? && instance_variable_defined?(:@_after_failed_fetch) @_after_failed_fetch else [] end end def _before_logout if Ractor.main? && instance_variable_defined?(:@_before_logout) @_before_logout else [] end end def _on_request if Ractor.main? && instance_variable_defined?(:@_on_request) @_on_request else [] end end RUBY end |
._install_warden_serializer_patch ⇒ Object
Warden registers per-scope session serializers with
Warden::SessionSerializer.send(:define_method, method_name, &block)
(warden-1.2.9/lib/warden/manager.rb:71). The block is created while the
app boots in the main Ractor, so the resulting method is Ractor-bound:
invoking it (e.g. user_serialize) from a worker Ractor raises
"defined with an un-shareable Proc in a different Ractor".
Devise's block body is simply mapping.to.serialize_into_session(record).
We re-register the serializers as plain def methods (which are NOT
Ractor-bound) that delegate to the model class's own
serialize_into_session / serialize_from_session class methods — both
worker-safe — so the chain is callable from any worker Ractor.
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 |
# File 'lib/ractor_rails_shim/patches/warden.rb', line 115 def _install_warden_serializer_patch return if @warden_serializer_patched @warden_serializer_patched = true _register_patch :warden_serializer, "8.1" return unless defined?(::Warden::SessionSerializer) if defined?(::Devise) && ::Devise.respond_to?(:mappings) ::Devise.mappings.each do |scope, mapping| model = mapping.to ::Warden::SessionSerializer.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{scope}_serialize(record) #{model}.serialize_into_session(record) end def #{scope}_deserialize(*keys) # Devise's serialize_into_session returns [[id], salt]. The key # passed by Warden::SessionSerializer#fetch is that value, but in # the kino :ractor worker the per-request session that Warden's # serializer sees can hold an extra wrapping layer # ([[[id], salt]]). Flatten so serialize_from_session(key, salt) # always receives exactly two arguments. #{model}.serialize_from_session(*keys.flatten) end RUBY end end ::Warden::SessionSerializer.class_eval do unless method_defined?(:serialize) def serialize(user) user end end unless method_defined?(:deserialize) def deserialize(key) key end end end end |
._install_warden_strategies_patch ⇒ Object
Patch Warden::Strategies#_strategies. The strategy registry is a lazy
class ivar (@strategies ||= {}) on the Warden::Strategies module; a
worker Ractor reading it raises "can not get unshareable values from
instance variables of classes/modules from non-main Ractors" (Devise's
current_user / user_signed_in? in a layout hits
Warden::Strategies -> _strategies). Capture the (shareable)
registry in main and expose it via a constant that workers read.
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/ractor_rails_shim/patches/warden.rb', line 78 def _install_warden_strategies_patch return if @warden_strategies_patched @warden_strategies_patched = true _register_patch :warden_strategies, "8.1" return unless defined?(::Warden::Strategies) ::Warden::Strategies.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def _strategies if Ractor.main? @strategies ||= {} else RactorRailsShim::SHAREABLE_WARDEN_STRATEGIES || {} end end RUBY if Ractor.main? begin strat = ::Warden::Strategies.instance_variable_get(:@strategies) strat = Ractor.make_shareable(strat) if strat && !Ractor.shareable?(strat) RactorRailsShim.const_set(:SHAREABLE_WARDEN_STRATEGIES, strat) unless RactorRailsShim.const_defined?(:SHAREABLE_WARDEN_STRATEGIES) rescue nil end end end |
._install_with_empty_template_cache_patch ⇒ Object
Patch ActionView::Base.with_empty_template_cache (action_view/base.rb:204)
to a block-free def. The original defines compiled_method_container
(instance + singleton) via define_method(&block) — an un-shareable Proc
compiled in the main Ractor that raises "defined with an un-shareable Proc
in a different Ractor" when a worker calls it. We also route compiled
template methods through ONE shared SHAREABLE_COMPILED_MODULE so the
application layout (and Devise shared partials) compile once and are
visible to every controller / worker Ractor.
Installed EARLY via ActiveSupport.on_load(:action_view) (see core.rb
install) so it is in place before production eager load calls
DetailsKey.view_context_class -> with_empty_template_cache. Idempotent.
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 43 def _install_with_empty_template_cache_patch return if @with_empty_template_cache_patched return unless defined?(::ActionView::Base) && Ractor.main? @with_empty_template_cache_patched = true _register_patch :with_empty_template_cache, "8.1" ::ActionView::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def with_empty_template_cache subclass = Class.new(self) do include RactorRailsShim::SHAREABLE_COMPILED_MODULE def compiled_method_container RactorRailsShim::SHAREABLE_COMPILED_MODULE end def self.compiled_method_container RactorRailsShim::SHAREABLE_COMPILED_MODULE end def inspect "#<ActionView::Base:\#{'%#016x' % (object_id << 1)}>" end end subclass end RUBY end |
._introspectable?(o) ⇒ Boolean
BasicObject (and its subclasses) don't define respond_to?, so calling o.respond_to? on one raises NoMethodError. Use this to safely test whether an object can be introspected (is_a?, instance_variables, ...).
552 553 554 555 556 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 552 def _introspectable?(o) o.respond_to?(:is_a?) rescue NoMethodError false end |
._make_value_shareable(val) ⇒ Object
Best-effort shareable replacement for a constant value. Monitor/Mutex become a NoOpLock (never contended post-boot). BasicObject instances (used as sentinel sentinels, e.g. PRIMARY_KEY_NOT_SET) can't be frozen (BasicObject has no #freeze method) — replace with a frozen Symbol. Everything else is deep-frozen via Ractor.make_shareable; if that fails (e.g. a Proc, or a Concurrent::Map / TypeMap holding Procs — both intrinsically unshareable and needing upstream Rails changes), returns nil and the constant is left as-is (the worker will raise a clear IsolationError on read).
277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 277 def _make_value_shareable(val) if (val.is_a?(::Monitor) rescue false) || (val.is_a?(::Mutex) rescue false) Ractor.make_shareable(NoOpLock.new) elsif !(val.respond_to?(:freeze) rescue false) # BasicObject subclasses don't have #freeze/#respond_to? (Kernel not # included). Replace with a frozen Symbol sentinel — it's compared # with `equal?`, and a frozen Symbol is always shareable. Ractor.make_shareable(:"__shim_unshareable_sentinel__") else begin Ractor.make_shareable(val) rescue => e nil end end end |
._neutralize_logger_io!(app) ⇒ Object
Detach the logger IO from the app graph so Ractor.make_shareable(app) doesn't freeze the process's real $stdout/$stderr. The app-instance logger (app.config.logger) holds an IO in its logdev; freezing it would silence main-ractor logging and break minitest/server output.
Strategy: replace app.config.logger (and any broadcast target reachable from the app) with a frozen, shareable no-op BroadcastLogger (no IO). Then re-point the MAIN ractor's Rails.logger (the per-Ractor module accessor, NOT in the app graph) at a fresh live BroadcastLogger writing to $stderr — which is NOT reachable from the frozen app, so it stays mutable. Workers already build their own per-Ractor Rails.logger in the patched reader, so they're unaffected.
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 140 def _neutralize_logger_io!(app) # A frozen, shareable no-op BroadcastLogger (no broadcasts → no IO) to # swap in for the app-instance logger graph. noop_logger = ::ActiveSupport::BroadcastLogger.new noop_logger.freeze Ractor.make_shareable(noop_logger) # Replace the app-instance logger + any IO reachable from the app graph. seen = {} stack = [app] until stack.empty? o = stack.pop next if o.equal?(nil) || seen[o.object_id] seen[o.object_id] = true begin o.instance_variables.each do |iv| begin; v = o.instance_variable_get(iv); rescue; next; end if iv == :@logger # Replace the app-instance / config logger with the no-op (so the # frozen app graph holds no live IO). Best-effort; rescue if the # owner is frozen. o.instance_variable_set(iv, noop_logger) rescue nil elsif v.is_a?(::IO) && (v == $stdout || v == $stderr || v == STDOUT || v == STDERR) # Any stray IO reference → a shareable no-op sink. sink = NoOpLogDev.new sink.freeze Ractor.make_shareable(sink) o.instance_variable_set(iv, sink) rescue nil elsif v stack << v end end rescue => e # BasicObject or frozen objects don't support instance_variables end if o.is_a?(Array); o.each { |e| stack << e if e } elsif o.is_a?(Hash); o.each { |_, val| stack << val if val } end end # Re-point the MAIN ractor's Rails.logger at a fresh live logger (not # reachable from the frozen app) so main keeps logging after the app is # made shareable. $stderr is each-Ractor-local and not in the app graph, # so it stays mutable. Use the same shape Rails uses (BroadcastLogger # broadcasting to a Logger writing $stderr). if Ractor.main? && defined?(::Rails) live = ::ActiveSupport::BroadcastLogger.new(::Logger.new($stderr)) ::Rails.logger = live end end |
._patch_rails_module_body(mod) ⇒ Object
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 |
# File 'lib/ractor_rails_shim/patches/rails_module.rb', line 61 def _patch_rails_module_body(mod) k = KEYS # Register the Rails module accessors in CLASS_ATTRIBUTES so the # shareable-fallback builder captures their main-ractor values at # prepare_for_ractors! / make_app_shareable! time and exposes them to # worker Ractors (e.g. Rails.logger is read per-request by # Rails::Rack::SilenceRequest). `application` is NOT registered — workers # get the shared app via Ractor.new(app), not via Rails.application. CLASS_ATTRIBUTES << ["Rails", :logger, k[:logger], nil] CLASS_ATTRIBUTES << ["Rails", :cache, k[:cache], nil] CLASS_ATTRIBUTES << ["Rails", :backtrace_cleaner, k[:backtrace_cleaner], nil] CLASS_ATTRIBUTES << ["Rails", :app_class, k[:app_class], nil] # We PREPEND a module onto Rails.singleton_class rather than redefine # the methods directly, because Rails defines its own `application`, # `env`, etc. LATER in rails.rb (`class << self; def application; ...`). # A direct module_eval redefinition would be clobbered when Rails' # own `def` runs afterward. A prepended module sits in front of the # singleton class in the method lookup chain and survives a later # `def` on the same class — so our IES-routed reader stays in front. # We call `super` to fall back to Rails' original method for the # main-ractor lazy-init path (which reads the @application ivar — # safe in the main ractor). patch = Module.new patch.module_eval <<-RUBY, __FILE__, __LINE__ + 1 # application: IES first; main ractor falls back to Rails' own # lazy init via super. Worker ractors return nil (their own IES # slot is empty until they boot their own app). def application v = ActiveSupport::IsolatedExecutionState[#{k[:application].inspect}] return v unless v.nil? if Ractor.main? super else RactorRailsShim.const_defined?(:SHAREABLE_APP) ? RactorRailsShim::SHAREABLE_APP : nil end end def application=(val) ActiveSupport::IsolatedExecutionState[#{k[:application].inspect}] = val super if Ractor.main? val end # Simple accessors: app_class, cache, logger, backtrace_cleaner. # Workers fall back to the shareable fallback (built from main's # value at make_app_shareable! time) when their own IES slot is empty. def app_class v = ActiveSupport::IsolatedExecutionState[#{k[:app_class].inspect}] return v unless v.nil? return super if Ractor.main? RactorRailsShim::SHAREABLE_FALLBACK[#{k[:app_class].inspect}] end def app_class=(val) ActiveSupport::IsolatedExecutionState[#{k[:app_class].inspect}] = val super if Ractor.main? val end def cache v = ActiveSupport::IsolatedExecutionState[#{k[:cache].inspect}] return v unless v.nil? return super if Ractor.main? RactorRailsShim::SHAREABLE_FALLBACK[#{k[:cache].inspect}] end def cache=(val) ActiveSupport::IsolatedExecutionState[#{k[:cache].inspect}] = val super if Ractor.main? val end def logger v = ActiveSupport::IsolatedExecutionState[#{k[:logger].inspect}] return v unless v.nil? return super if Ractor.main? # Loggers are intrinsically mutable (formatters hold tag stacks, # logdevs hold IO + Mutex) and can't be shared read-only. Build a # per-worker ActiveSupport::BroadcastLogger (which mixes in # LoggerSilence#silence, used by Rails::Rack::SilenceRequest) # writing to $stderr (each Ractor has its own $stderr stream) and # cache it in IES so subsequent reads return the same instance. built = ActiveSupport::BroadcastLogger.new(Logger.new($stderr)) ActiveSupport::IsolatedExecutionState[#{k[:logger].inspect}] = built built end def logger=(val) ActiveSupport::IsolatedExecutionState[#{k[:logger].inspect}] = val super if Ractor.main? val end def backtrace_cleaner v = ActiveSupport::IsolatedExecutionState[#{k[:backtrace_cleaner].inspect}] return v unless v.nil? return super if Ractor.main? RactorRailsShim::SHAREABLE_FALLBACK[#{k[:backtrace_cleaner].inspect}] end def backtrace_cleaner=(val) ActiveSupport::IsolatedExecutionState[#{k[:backtrace_cleaner].inspect}] = val super if Ractor.main? val end # env: worker ractors build their own EnvironmentInquirer from ENV # (no @ _env ivar to read). Main ractor falls back to super, which # lazily builds and caches in @_env. def env v = ActiveSupport::IsolatedExecutionState[#{k[:env].inspect}] return v unless v.nil? if Ractor.main? super else built = ActiveSupport::EnvironmentInquirer.new( ENV["RAILS_ENV"].presence || ENV["RACK_ENV"].presence || "development" ) ActiveSupport::IsolatedExecutionState[#{k[:env].inspect}] = built built end end def env=(val) v = ActiveSupport::EnvironmentInquirer.new(val) ActiveSupport::IsolatedExecutionState[#{k[:env].inspect}] = v super if Ractor.main? v end RUBY mod.singleton_class.prepend(patch) end |
._precompute_lazy_ivars(app) ⇒ Object
--- graph traversal helpers ---
484 485 486 487 488 489 490 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 484 def _precompute_lazy_ivars(app) app.env_config app.app_env_config rescue nil app.routes.url_helpers rescue nil app.routes.named_routes rescue nil app.routes.helpers rescue nil end |
._precompute_propshaft!(app) ⇒ Object
Warm every Propshaft Assembly/LoadPath lazy ivar in MAIN (before the app
is frozen). Warmed ivars are truthy, so the ||= memoization in workers
short-circuits instead of attempting to assign onto a frozen object.
Assets::by_path is itself memoized; warming assets populates it so
workers only read the frozen cache.
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/ractor_rails_shim/patches/propshaft.rb', line 73 def _precompute_propshaft!(app) return unless Ractor.main? assets = app.assets rescue nil return unless assets assets.load_path rescue nil assets.compilers rescue nil assets.resolver rescue nil assets.prefix rescue nil assets.processor rescue nil # Build the asset map (LoadPath#assets -> assets_by_path) so the inner # cache is populated and frozen before workers read it. Then warm each # Asset's lazy ivars (@content_type, @digest) in MAIN — they are memoized # via `||=` and the Asset objects live inside the frozen, shared app # graph, so a worker reading them would raise FrozenError. Warming here # (while still mutable) lets the `||=` short-circuit in workers. assets.load_path.assets.each do |asset| asset.content_type rescue nil asset.digest rescue nil end # Warm the resolver manifest cache. Propshaft::Resolver::Static#manifest # memoizes `@manifest ||= Propshaft::Manifest.from_path(...)`; the # resolver is frozen in the shared graph, so without warming, a worker # rendering a `stylesheet_link_tag` (or reading asset integrity) for a # PRECOMPILED manifest raises FrozenError ("can't modify frozen # Propshaft::Resolver::Static"). `#manifest` is private — invoke via # send to trigger the `||=` in MAIN (while still mutable) so workers only # read the frozen, cached value. resolver = assets.resolver rescue nil if resolver.respond_to?(:manifest, true) begin resolver.send(:manifest) rescue nil end end end |
._record_declared_callback(klass_id, kind, filter, only, except) ⇒ Object
Record a single declared symbolic filter. Called from the set_callback interceptor during eager load (main Ractor only).
764 765 766 767 768 769 770 771 772 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 764 def _record_declared_callback(klass_id, kind, filter, only, except) @declared_callbacks ||= {} (@declared_callbacks[klass_id] ||= []) << { kind: kind, filter: filter, only: (only.freeze if only), except: (except.freeze if except) } end |
._register_patch(name, *tested_segments) ⇒ Object
Record that a patch was developed/tested against the given Rails version
segments. Called by each install_* method. This populates PATCH_VERSIONS
so applicable_patches can report what applied to the runtime. To add
support for a new version, add the segment here (after writing/testing
the variant) — no other wiring needed.
488 489 490 491 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 488 def _register_patch(name, *tested_segments) existing = PATCH_VERSIONS[name] || [] PATCH_VERSIONS[name] = (existing + tested_segments).uniq end |
._replace_locks_and_concurrent_maps!(app) ⇒ Object
NOTE: _devise_mapping_replacement (Devise scope constraint →
DeviseMappingCallable) now lives in warden.rb; _find_files_server
(Rack::Files target for the asset stack) now lives in rack.rb.
656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 656 def _replace_locks_and_concurrent_maps!(app) seen = {} stack = [[app, "app", nil, nil]] until stack.empty? o, _p, _parent, _ivar = stack.pop next if o.equal?(nil) next unless _introspectable?(o) next if seen[o.object_id] seen[o.object_id] = true next if o.is_a?(Mutex) || o.is_a?(Monitor) begin o.instance_variables.each do |iv| begin; v = o.instance_variable_get(iv); rescue; next; end if v.is_a?(Mutex) || v.is_a?(Monitor) o.instance_variable_set(iv, NoOpLock.new) rescue nil elsif defined?(::Concurrent::Map) && v.is_a?(::Concurrent::Map) hash_copy = {} v.each_pair { |k, val| hash_copy[k] = val } o.instance_variable_set(iv, hash_copy) rescue nil elsif v stack << [v, "#{_p}.#{iv}", o, iv] end end rescue => e # BasicObject or frozen objects don't support instance_variables end if o.is_a?(Array); o.each_with_index { |e, i| stack << [e, "#{_p}[#{i}]", o, nil] if e } elsif o.is_a?(Hash); o.each { |k, val| stack << [k, "#{_p}.key", o, nil] if k; stack << [val, "#{_p}[#{k.inspect}]", o, nil] if val } end end end |
._replace_one_proc(proc_obj, parent, ivar, mw) ⇒ Object
598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 598 def _replace_one_proc(proc_obj, parent, ivar, mw) src = proc_obj.source_location&.first || "" replacement = if src.end_with?(SSL_LOC) && ivar == :@exclude redirect = parent.instance_variable_get(:@redirect) CallableConst.new(!redirect) elsif src.end_with?(FILES_LOC) && ivar == :@app # The lambda is `Rack::Files#initialize`'s `lambda { |env| get env }`, # stored as `Rack::Head#@app`. Its `self` (binding receiver) is the # `Rack::Files` instance that defines `get` — NOT the `Rack::Head` # that holds it. Use the binding receiver as the callable target so # the worker calls `Rack::Files#get(env)` (the original behavior). # Fall back to the middleware-chain search if the receiver can't be # resolved (e.g. frozen/unavailable binding). receiver = proc_obj.binding.receiver rescue nil files_server = receiver if receiver && receiver.respond_to?(:get) files_server ||= _find_files_server(mw) files_server ||= parent Callable.new(files_server, :get) elsif src.end_with?(COOKIE_LOC) RequestCallable.new(:cookies_same_site_protection) elsif src.end_with?(DEVISE_SCOPE_LOC) _devise_mapping_replacement(proc_obj, parent) elsif src.end_with?(MAPPER_LOC) && ivar == :@strategy line = proc_obj.source_location[1] line == 32 ? StrategyServe.new : StrategyCall.new else NoOpProc.new end if ivar == :__default_proc__ # The parent Hash may already be frozen (e.g. by an earlier # shareability pass on AR internals). A frozen Hash can't have its # default cleared, but a frozen Hash with a default_proc is still # unshareable — Ractor.make_shareable(parent) later will replace it # wholesale if needed. Just skip here when frozen. begin parent.default = nil rescue FrozenError, RuntimeError # frozen Hash — leave the default_proc; make_shareable handles it. end elsif ivar parent.instance_variable_set(ivar, replacement) rescue nil elsif parent.is_a?(Array) idx = parent.index(proc_obj) if idx then parent[idx] = replacement else parent.each_with_index { |e, i| parent[i] = replacement if e.equal?(proc_obj) } end elsif parent.is_a?(Hash) key = parent.key(proc_obj) parent[key] = replacement if key rescue nil end end |
._replace_unshareable_procs!(app) ⇒ Object
Replace every Proc in the app graph with a callable/no-op object. Multiple passes because the same Proc object can live in many containers (e.g. deprecation behaviors shared across deprecators). Doesn't dedup procs — must replace every occurrence.
540 541 542 543 544 545 546 547 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 540 def _replace_unshareable_procs!(app) mw = (app.instance_variable_get(:@app) rescue nil) 3.times do procs = _collect_procs(app) break if procs.empty? procs.each { |proc_obj, _path, parent, ivar| _replace_one_proc(proc_obj, parent, ivar, mw) } end end |
._share_active_record_internals! ⇒ Object
ActiveRecord's internal helper classes (the *Clause classes used while
building a relation's Arel) cache a frozen "empty" singleton in a class
instance variable via @empty ||= new(...).freeze (e.g.
ActiveRecord::Relation::WhereClause#empty). Reading that class ivar from
a worker Ractor raises Ractor::IsolationError if the value isn't
shareable. Fix: warm .empty in the main Ractor (populating @empty with
its frozen singleton), then make every class ivar on these helper
classes shareable. Idempotent; must run in the main Ractor.
ActiveRecord's internal helper classes/modules hold class instance
variables that a worker Ractor reads during query building / connection
establishment (e.g. ActiveRecord::ConnectionAdapters.@adapters — a Hash
of adapter_name => [class_name, path]; the *Clause classes' @empty
frozen singleton). A class ivar whose VALUE is shareable IS readable from
a worker Ractor (unlike class variables), so we deep-freeze each value
in the main Ractor and write it back (Monitor/Mutex->NoOpLock,
Concurrent::Map->frozen Hash, etc. via _shareable_ivar_replacement).
TARGETED: only specific leaf registries (not a broad ActiveRecord::* sweep). A broad sweep also freezes AR-railtie initializer Collections reachable from the app graph, which breaks make_app_shareable's proc-replacement (frozen containers can't be mutated to swap Procs).
488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 488 def _share_active_record_internals! return unless defined?(::ActiveRecord::Base) # 1. Warm + freeze the *Clause `.empty` singletons. ObjectSpace.each_object(Class) do |c| n = c.name next unless n && n.start_with?("ActiveRecord::Relation::") && n.end_with?("Clause") begin c.empty if c.respond_to?(:empty) rescue => e end _freeze_class_ivars!(c) end # 2. ConnectionAdapters.@adapters (String => [class_name, path]) — read # by `ConnectionAdapters.resolve` during establish_connection. if defined?(::ActiveRecord::ConnectionAdapters) _freeze_class_ivars!(::ActiveRecord::ConnectionAdapters) end # 3. ActiveRecord::Type — holds @default_value (a lazy singleton Value # used as a fallback type). Warm it and freeze the class ivar. if defined?(::ActiveRecord::Type) ::ActiveRecord::Type.default_value rescue nil _freeze_class_ivars!(::ActiveRecord::Type) end end |
._share_model_classes! ⇒ Object
ActiveRecord model classes lazily initialize many class instance
variables on first use (e.g. @table_name, @arel_table, @predicate_builder,
@columns_hash, @attribute_methods_module) via @ivar ||= compute. The
computation is deterministic, but it WRITES the class ivar — which a
worker Ractor cannot do (Ractor::IsolationError: "can not set instance
variables of classes/modules by non-main Ractors").
Fix: in the main Ractor, warm every model class by running representative
queries (count / first / page), which populates all the lazy class ivars
with their shareable-or-not values. Then make every class ivar's VALUE
shareable (deep-freeze via Ractor.make_shareable) and write it back while
the class is still mutable in main. A class ivar holding a shareable value
is readable from a worker Ractor, and the worker's ||= short-circuits
(no write). Idempotent; must run in the main Ractor after eager_load.
359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 359 def _share_model_classes! return unless defined?(::ActiveRecord::Base) classes = [::ActiveRecord::Base] classes.concat(::ActiveRecord::Base.descendants) rescue nil classes.each do |klass| # Warm the class's lazy ivars by actually exercising the query paths # the workers will hit. Main has a working connection handler, so this # populates exactly the ivars a real query touches. # Warm the class's lazy ivars by actually exercising the query paths # the workers will hit. Main has a working connection handler, so this # populates exactly the ivars a real query touches. Each call is # isolated: a failure in one must not skip the rest (e.g. the private # `relation` method or a cold connection must not prevent `table_name` # from being set). warm_calls = [ -> { klass.connection_pool if klass.respond_to?(:connection_pool) }, -> { klass.send(:reset_primary_key) if klass.respond_to?(:reset_primary_key, true) }, -> { klass.table_name }, -> { klass.arel_table }, -> { klass.columns_hash }, -> { klass.attribute_names }, -> { klass.attribute_types }, -> { klass.predicate_builder }, -> { klass.defined_enums if klass.respond_to?(:defined_enums) }, -> { klass.send(:relation) if klass.respond_to?(:relation, true) }, -> { klass.count rescue nil }, -> { klass.first rescue nil }, -> { klass.send(:query_constraints_list) if klass.respond_to?(:query_constraints_list, true) }, -> { if defined?(::Kaminari) && klass.respond_to?(:page) klass.page(1).to_a rescue nil end }, ] warm_calls.each { |c| begin; c.call; rescue => e; end } # Make every class ivar shareable and write it back. The class is # still mutable here (in main), so the write is allowed. begin klass.instance_variables.each do |iv| v = klass.instance_variable_get(iv) rescue nil next unless v next if Ractor.shareable?(v) replacement = _shareable_ivar_replacement(v) next unless replacement begin klass.instance_variable_set(iv, replacement) rescue => e # frozen owner — leave as-is end end rescue => e # BasicObject / frozen owners end end # Capture each model's primary_key into a shareable snapshot. Workers # read this instead of the raw @primary_key class ivar (which starts as # PRIMARY_KEY_NOT_SET, a BasicObject that can't be made shareable). begin pk_map = {} classes.each do |klass| n = klass.name next unless n pk = klass.primary_key rescue next pk_map[n] = pk if pk end shareable = Ractor.make_shareable(pk_map) verbose, $VERBOSE = $VERBOSE, nil begin const_set(:AR_PRIMARY_KEYS_SHAREABLE, shareable) ensure $VERBOSE = verbose end rescue => e # best-effort end end |
._share_relation_delegate_caches! ⇒ Object
Make every loaded AR model class's @relation_delegate_cache shareable. Idempotent; must run in the main Ractor after eager_load so that all model classes (and their caches) exist.
326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 326 def _share_relation_delegate_caches! return unless defined?(::ActiveRecord::Base) classes = [::ActiveRecord::Base] classes.concat(::ActiveRecord::Base.descendants) rescue nil classes.each do |klass| cache = klass.instance_variable_get(:@relation_delegate_cache) rescue nil next unless cache next if Ractor.shareable?(cache) begin klass.instance_variable_set(:@relation_delegate_cache, Ractor.make_shareable(cache)) rescue => e # Best-effort: if a cache holds an unshareable delegate class we # can't freeze, skip it. The worker will then hit a clear error on # the first relation method and we can patch that class specifically. end end end |
._shareable_copy(val) ⇒ Object
Return a fresh copy of a mutable default container (Hash/Array) so the fallback entry is independent. Frozen/shareable defaults pass through.
332 333 334 335 336 337 338 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 332 def _shareable_copy(val) case val when Hash then val.dup when Array then val.dup else val end end |
._shareable_ivar_replacement(v) ⇒ Object
- Monitor/Mutex -> NoOpLock (never contended post-boot)
- Concurrent::Map -> frozen Hash
- else -> Ractor.make_shareable; if that fails (statement caches etc.), a frozen empty container of the same kind so the worker reads a shareable value (cold cache in workers; slower, correct). Returns nil if no replacement can be made.
443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 443 def _shareable_ivar_replacement(v) if v.is_a?(::Monitor) || v.is_a?(::Mutex) Ractor.make_shareable(NoOpLock.new) elsif defined?(::Concurrent::Map) && v.is_a?(::Concurrent::Map) h = {} begin v.each_pair { |k, val| h[k] = val } rescue => e end Ractor.make_shareable(h) else begin Ractor.make_shareable(v) rescue => e case v when ::Hash then Ractor.make_shareable({}) when ::Array then Ractor.make_shareable([]) when ::Set then Ractor.make_shareable(::Set.new) else nil end end end end |
._try_make_shareable(val, owner_name, attr_name, default: false) ⇒ Object
Best-effort attempt to make val shareable (callable-replacement for
Procs + lock-replacement + make_shareable). Returns the shareable val,
or nil if it can't be made shareable. On failure, emits a warning
(unless default: — defaults are expected to sometimes be unshareable,
so we skip the noise).
304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 304 def _try_make_shareable(val, owner_name, attr_name, default: false) # __callbacks and validators hold callback chains / validator instances # with self-capturing Procs that can NEVER be made shareable. This is # expected: workers correctly treat callbacks as already-run (the # nil-safe run_callbacks patch yields the block directly). Skip the # attempt entirely — don't waste cycles traversing the graph, and don't # emit warnings for known-expected failures. attr_sym = attr_name.to_s return nil if attr_sym.end_with?("__callbacks") || attr_sym.end_with?("__validators") || attr_sym.end_with?("default_connection_handler") begin _replace_unshareable_procs!(val) _replace_locks_and_concurrent_maps!(val) Ractor.make_shareable(val) val rescue => e unless default warn "ractor-rails-shim: could not make attribute " \ "#{owner_name}##{attr_name} shareable (#{e.class}: #{e.[0,80]}); workers will fall back to default or nil" end nil end end |
._version_mismatch(message) ⇒ Object
Apply the configured policy to a mismatch message.
455 456 457 458 459 460 461 462 463 464 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 455 def _version_mismatch() case version_policy when :strict raise UnsupportedVersionError, when :off # silent else warn end end |
._warm_active_record_class_caches! ⇒ Object
Warm ActiveRecord model classes' lazily-computed, shareable class-ivar
memoizations in the MAIN Ractor, BEFORE the graph is frozen. Methods like
the timestamp_attribute_* helpers cache frozen Arrays of strings (shareable
once warmed), so pre-populating them here lets workers read via ||=
without ever setting the class ivar. (Class ivars holding unshareable
values are handled by _freeze_active_record_class_ivars!.)
729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 729 def _warm_active_record_class_caches! return unless defined?(::ActiveRecord::Base) models = [::ActiveRecord::Base] + (::ActiveRecord::Base.descendants rescue []) warmers = %i[ timestamp_attributes_for_create_in_model timestamp_attributes_for_update_in_model all_timestamp_attributes_in_model sequence_name columns column_names attribute_names column_defaults symbol_column_to_string_name_hash returning_columns_for_insert yaml_encoder attribute_types ] models.each do |klass| next if klass.abstract_class? warmers.each do |m| next unless klass.respond_to?(m, true) begin klass.send(m) rescue nil end end end end |
._warm_attribute_method_patterns! ⇒ Object
Build + freeze ActiveModel's per-class attribute_method_patterns_cache
(and attribute_method_matchers) in the MAIN Ractor for every loaded
model. These are lazy class ivars populated on the first respond_to?
call; they hold an Array of [Regexp, Symbol] pairs (shareable elements)
but the Array itself is mutable and therefore NOT Ractor-shareable. A
worker Ractor reading the ivar raises
Ractor::IsolationError: can not get unshareable values from instance variables of classes/modules. redirect_to @post calls
Post#respond_to?(:to_model) in the worker, which reads this cache, so
the write-path 302 redirect dies. Building it in MAIN (where it is
reachable) and freezing the Array makes it shareable; the cache is never
mutated after build (attribute_method_patterns_matching only does
.select on it), so freezing is safe.
519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 519 def _warm_attribute_method_patterns! return unless defined?(::ActiveRecord::Base) ::ActiveRecord::Base.descendants.each do |klass| next unless klass.respond_to?(:attribute_method_patterns_cache, true) begin cache = klass.send(:attribute_method_patterns_cache) cache.freeze if cache if klass.respond_to?(:attribute_method_matchers, true) matchers = klass.send(:attribute_method_matchers) matchers.freeze if matchers end rescue StandardError nil end end end |
._warm_journey_routes! ⇒ Object
Warm + cache @ast / @simulator on the live Routes graph. Called from
make_app_shareable! AFTER the route precompute (which resets the routes)
and BEFORE Ractor.make_shareable freezes the graph. Must run in the main
Ractor. Idempotent (caches on the mutable object, then frozen in place).
744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 744 def _warm_journey_routes! return unless Ractor.main? _freeze_journey_visitors! _freeze_mime_negotiation! begin rs = ::Rails.application.routes rescue nil # Navigate to the ActionDispatch::Journey::Routes object — the one that # is frozen into the shared graph and read (via Router#simulator) on # every request. Rails wraps it in a RouteSet (and sometimes a # LazyRouteSet), neither of which defines #simulator, so calling # `rs.routes.simulator` would silently NoMethodError and leave @simulator # uncached — forcing worker Ractors to rebuild the simulator (and hit # Ractor::IsolationError on GTG constants). Descend through #routes until # we reach the Journey::Routes instance. routes = rs while routes.respond_to?(:routes) && !routes.is_a?(::ActionDispatch::Journey::Routes) nxt = routes.routes break if nxt.equal?(routes) routes = nxt end if routes.is_a?(::ActionDispatch::Journey::Routes) routes.ast routes.simulator _warm_path_patterns!(routes) end rescue => e # best-effort end end |
._warm_path_patterns!(routes) ⇒ Object
Pre-compute every Journey::Path::Pattern's lazy memoized ivars
(@required_names, @optional_names, @offsets, @re,
@requirements_for_missing_keys_check) on the LIVE (unfrozen) pattern,
before Ractor.make_shareable freezes the graph. Path::Pattern#match
(called on every request during route recognition) memoizes @offsets
via @offsets ||= ...; on a frozen pattern that write raises
FrozenError. By computing it now (and caching into the frozen object),
the worker reads the cached value and never writes. We deliberately do
NOT call the built-in eager_load!, which sets @ast = nil (the
@ast/@spec are still read by requirements_anchored? and must survive).
722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 722 def _warm_path_patterns!(routes) return unless routes.respond_to?(:routes) routes.routes.each do |r| p = r.respond_to?(:path) ? r.path : nil next unless p begin p.required_names p.optional_names p.send(:offsets) p.to_regexp p.requirements_for_missing_keys_check if p.respond_to?(:requirements_for_missing_keys_check) rescue # best-effort — a pattern we can't warm will fall back to its own # (non-frozen) copy if one exists; ignore unusual shapes. end end end |
.applicable_patches ⇒ Object
Report which registered patches apply to the runtime Rails version (and which were skipped because they're untested on it). Useful for diagnostics and CI. Returns a Hash: { applied: [...], skipped: [...] }.
469 470 471 472 473 474 475 476 477 478 479 480 481 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 469 def applicable_patches seg = RactorRailsShim::Version.rails_segment applied = [] skipped = [] PATCH_VERSIONS.each do |name, tested| if seg.nil? || tested.include?(seg) applied << name else skipped << { name: name, tested: tested, runtime: seg } end end { applied: applied, skipped: skipped } end |
.autoload_install! ⇒ Object
Require this gem and the patches auto-install IF Rails is loaded.
If Rails isn't loaded yet, install is deferred to the first call
of install (call it from config/boot.rb before Rails.application).
25 26 27 |
# File 'lib/ractor_rails_shim.rb', line 25 def autoload_install! install if defined?(::Rails) && !installed? end |
.capture_app_constants ⇒ Object
Capture a frozen name -> object map for every constant the application's Zeitwerk loaders manage. Runs in the MAIN Ractor, after eager load, where all app constants are defined. The map travels to worker Ractors, which use it to (re)bind the constant names into their own namespaces.
Why this is needed: a Ractor boundary does NOT share top-level constant
names — only the class/module objects reachable from the frozen shared
app graph cross the boundary. A worker Ractor therefore sees
RactorRailsShim, ActiveRecord, ApplicationRecord, the controllers,
etc. (objects reachable from the app), but NOT the application's own
model constants (e.g. Post): the object is in the graph, but its name
is not bound in the worker, so PostsController#index's Post reference
raises NameError. Rebinding the captured names fixes it without
re-running autoloading (which is itself impossible in a worker, since
Zeitwerk::Loader.new raises IsolationError off the main Ractor).
508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 508 def capture_app_constants map = {} return map unless defined?(::Rails) && Rails.respond_to?(:autoloaders) [Rails.autoloaders.main, Rails.autoloaders.once].each do |loader| next unless loader.respond_to?(:all_expected_cpaths) begin loader.all_expected_cpaths.values.each do |cpath| obj = Object.const_get(cpath) rescue next begin Ractor.make_shareable(obj) unless Ractor.shareable?(obj) rescue next end map[cpath] = obj if Ractor.shareable?(obj) end rescue => e warn "[ractor_rails_shim] capture_app_constants: #{e.class}: #{e.}" end end map.freeze end |
.do_install_shareable_constants ⇒ Object
Run after Rails is fully booted (after Rails.application.initialize!)
and BEFORE spawning worker Ractors. Re-attempts to make every
registered constant shareable; constants that didn't exist at install
time (e.g. Rails::Railtie, loaded after module Rails opens) get
fixed here. Safe to call multiple times; already-shareable constants
are no-ops.
This MUST run in the main Ractor (const_set writes the constant table).
Public wrapper is prepare_for_ractors! above.
234 235 236 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 234 def do_install_shareable_constants shareable_constants.each { |path| make_constant_shareable(path) } end |
.fix_url_helpers_singleton_routes ⇒ Object
The module-level singleton _routes (set via redefine_singleton_method
in the gem's included block) is still a define_method block. It is not
on the request hot-path, but make it shareable anyway. Called after the
module has been included into ActionView/ActionController (i.e. from
prepare_for_ractors!).
106 107 108 109 110 111 112 113 114 115 116 117 |
# File 'lib/ractor_rails_shim/patches/route_helpers.rb', line 106 def fix_url_helpers_singleton_routes return unless defined?(::Rails) && ::Rails.application return unless ::Rails.application.routes.respond_to?(:url_helpers) mod = ::Rails.application.routes.url_helpers return unless mod.respond_to?(:singleton_class) mod.singleton_class.class_eval do def _routes ::Rails.application.routes end end rescue end |
.init_worker_ar_connections! ⇒ Object
Worker-Ractor hook: create a fresh ConnectionHandler and establish connections from the captured configurations snapshot. Call this in each worker Ractor before serving requests:
Ractor.new(app) do |a|
RactorRailsShim.init_worker_ar_connections!
a.call(env)
end
Idempotent: safe to call multiple times (subsequent calls are no-ops once the handler is established). Uses Ractor.store_if_absent semantics via IES.
227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 227 def init_worker_ar_connections! return if Ractor.main? return unless defined?(::ActiveRecord::Base) key = :active_record_connection_handler existing = ActiveSupport::IsolatedExecutionState[key] return if existing # Establish a fresh, per-Ractor connection handler + pool from the # captured configurations snapshot. We call ConnectionHandler#establish_connection # DIRECTLY (not ActiveRecord::Base.establish_connection, which writes # the `@resolved_config` class ivar from the worker -> IsolationError). # ConnectionHandler#establish_connection reads ActiveRecord::Base.configurations # (now IES-routed by _install_activerecord_configurations_patch) inside # resolve_pool_config, so it works from a worker. Best-effort per config. snapshot = AR_CONFIGURATIONS_SNAPSHOT if snapshot && !snapshot.empty? env = ENV["RAILS_ENV"].presence || ENV["RACK_ENV"].presence || "development" env_configs = snapshot[env] || snapshot.values.first || {} handler = ::ActiveRecord::ConnectionAdapters::ConnectionHandler.new env_configs.each do |_name, config| begin handler.establish_connection(config, owner_name: ::ActiveRecord::Base, role: ::ActiveRecord::Base.current_role || :writing, shard: ::ActiveRecord::Base.current_shard || :default) rescue => e # Best-effort: if one connection fails, continue with others. end end ActiveSupport::IsolatedExecutionState[key] = handler end end |
.install ⇒ Object
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 155 def install _check_version_support @thread_mode = !!(ENV["SERVER"] && ENV["SERVER"] =~ /puma|falcon|thin|webrick|thread/i) unless defined?(@thread_mode) if thread_mode? # Minimal install for thread (Puma/Falcon) servers: only the # class_attribute isolation fix + nil-safe callback replay. The other # patches route framework globals through per-Ractor IES, which is # empty on Puma's request threads and would break the app, so they are # skipped; the original Rails globals are thread-safe and used as-is. install_class_attribute install_execution_wrapper # Capture each controller's OWN declared before_action/after_action # filters at declaration time (during eager load) by intercepting # ActiveSupport::Callbacks.set_callback. This must be installed BEFORE # eager load so declarations are captured as they happen — the # class_attribute callback chain is corrupted by an eager-load leak # under Ruby 4.0.5 + Rails 8.1.3 + Devise, so reading __callbacks later # yields a wrong, unshareable chain. Install requires active_support/ # callbacks to be loaded, so require it first; install runs before the # app's eager_load, so every controller declaration is captured. require "active_support/callbacks" rescue nil _install_callback_declaration_capture! else install_mattr_accessor install_class_attribute install_zeitwerk_registry install_rubygems install_rails_module install_shareable_constants install_execution_wrapper require "active_support/callbacks" rescue nil _install_callback_declaration_capture! # Patch ActionView::Base.with_empty_template_cache EARLY (before eager # load) so production's DetailsKey.view_context_class uses the block-free # version. The framework's original defines compiled_method_container via # define_method(&block) — an un-shareable Proc that breaks worker # Ractors. on_load fires as soon as ActionView is required, well before # the app's eager_load. ActiveSupport.on_load(:action_view) do RactorRailsShim._install_with_empty_template_cache_patch end end @installed = true true end |
.install_class_attribute ⇒ Object
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
# File 'lib/ractor_rails_shim/patches/class_attribute.rb', line 23 def install_class_attribute _register_patch :class_attribute, "8.1" return if @class_attr_patched @class_attr_patched = true if defined?(::ActiveSupport::ClassAttribute) patch_class_attribute! else # Defer until ActiveSupport::ClassAttribute loads. A TracePoint(:class) # fires when `module ClassAttribute` opens. One-shot. @ca_tp = TracePoint.new(:class) do |trace| if defined?(::ActiveSupport::ClassAttribute) && !@ca_patched @ca_tp.disable patch_class_attribute! end end @ca_tp.enable end end |
.install_execution_wrapper ⇒ Object
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
# File 'lib/ractor_rails_shim/patches/execution_wrapper.rb', line 17 def install_execution_wrapper return if @exec_wrapper_patched @exec_wrapper_patched = true _register_patch :execution_wrapper, "8.1" if defined?(::ActiveSupport::ExecutionWrapper) patch_execution_wrapper! else @ew_tp = TracePoint.new(:class) do |trace| if defined?(::ActiveSupport::ExecutionWrapper) && !@exec_wrapper_registry_patched @ew_tp.disable patch_execution_wrapper! end end @ew_tp.enable end end |
.install_mattr_accessor ⇒ Object
9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 |
# File 'lib/ractor_rails_shim/patches/mattr_accessor.rb', line 9 def install_mattr_accessor _register_patch :mattr_accessor, "8.1" return if @mattr_patched @mattr_patched = true ::Module.prepend(Module.new { # The prepended module's body is evaluated in the main ractor at # prepend time; the methods it defines are callable from any ractor # because they're defined via string eval (no captured binding). # But mattr_accessor itself runs at app boot in the main ractor, and # the per-accessor redefinition must also use string eval. # # IMPORTANT: Rails' mattr_accessor/cattr_accessor stores values in # CLASS VARIABLES (@@sym), not class instance variables (@sym). The # default value is written via class_variable_set("@@sym", default). # Class variables are also subject to Ractor::IsolationError from # non-main ractors (verified on Ruby 4.0.5), so we route through IES # the same way — but the main-ractor fallback must read @@sym, and # the seed must run in the main ractor at define-time (via super). def mattr_accessor(*syms, instance_reader: true, instance_writer: true, instance_accessor: true, default: nil, **kwargs, &block) shareable = kwargs[:shareable] mod_name = name # Compute the default value the same way Rails does, so we can # seed worker-ractor IES slots with it (workers can't read @@sym). # The block form is evaluated once here (in main ractor) like Rails. sym_default = block_given? && default.nil? ? yield : default super # define the methods via the original path (sets @@sym) syms.each do |sym| key = :"ractor_rails_shim_mattr_#{mod_name}_#{sym}" key_str = key.inspect cv = "@@#{sym}" cv_str = cv.inspect # Register so _build_shareable_fallback! can capture the main-ractor # value (read from @@sym) at prepare_for_ractors! time. The label # is just for diagnostics. The default is stored too so the # fallback builder can use it when the live value can't be shared. RactorRailsShim::CLASS_ATTRIBUTES << [mod_name, sym, key, sym_default] # Store the default in a runtime registry (NOT inlined into the # eval'd method body — arbitrary objects like Logger have invalid # `.inspect` output). The reader looks it up by key. RactorRailsShim::MATTR_DEFAULTS[key] = sym_default # If the default is shareable, add to the shareable subset. We # rebuild the constant as a new frozen shareable Hash each time # (so workers can read the constant even before prepare_for_ractors! # runs — e.g. unit tests). const_set warns "already initialized # constant"; silence it. if sym_default && Ractor.shareable?(sym_default) h = RactorRailsShim::SHAREABLE_MATTR_DEFAULTS.dup h[key] = sym_default h.freeze Ractor.make_shareable(h) verbose, $VERBOSE = $VERBOSE, nil begin RactorRailsShim.const_set(:SHAREABLE_MATTR_DEFAULTS, h) ensure $VERBOSE = verbose end end # Redefine the class reader via string eval (no captured binding). # Class variables are only touched from the main ractor; worker # ractors fall back to SHAREABLE_FALLBACK (built from main's @@sym # at prepare_for_ractors! time) when their own IES slot is empty. # NOTE: we deliberately do NOT inline the default value here — # arbitrary objects (e.g. Logger) have invalid `.inspect` output. # The fallback builder captures the live value (which may equal # the default) at prepare time. singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{sym} v = ActiveSupport::IsolatedExecutionState[#{key_str}] return v unless v.nil? if #{!!shareable} if class_variable_defined?(#{cv_str}) class_variable_get(#{cv_str}) end elsif Ractor.main? if class_variable_defined?(#{cv_str}) class_variable_get(#{cv_str}) else nil end else # Worker: try the shareable fallback (built from main's @@sym # at prepare_for_ractors! time). If empty, try the # definition-time default (only the shareable subset — the # full MATTR_DEFAULTS holds unshareable defaults like Logger # which workers can't read via the constant). fb = RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}] return fb unless fb.nil? RactorRailsShim::SHAREABLE_MATTR_DEFAULTS[#{key_str}] end end def #{sym}=(val) ActiveSupport::IsolatedExecutionState[#{key_str}] = val if Ractor.main? class_variable_set(#{cv_str}, val) if class_variable_defined?(#{cv_str}) class_variable_set(#{cv_str}, val) unless class_variable_defined?(#{cv_str}) end val end RUBY # Instance readers/writers route through IES directly (NOT # self.class.#{sym}). Rails' original uses @@sym (a class variable # inherited by including classes); the shim routes through IES, # so the instance reader must also use IES. Using self.class.sym # would fail for mattr_accessor on Modules (e.g. # ActionView::Helpers::FormHelper#form_with_generates_ids): # self.class is the including class (ActionView::Base), which # doesn't have the module's singleton method. # Only redefine if instance_accessor is on (matches Rails). if instance_reader && instance_accessor module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{sym} v = ActiveSupport::IsolatedExecutionState[#{key_str}] return v unless v.nil? if Ractor.main? self.class.class_variable_defined?(#{cv_str}) ? self.class.class_variable_get(#{cv_str}) : nil else RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}] end end RUBY end if instance_writer && instance_accessor module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{sym}=(val) ActiveSupport::IsolatedExecutionState[#{key_str}] = val self.class.class_variable_set(#{cv_str}, val) if Ractor.main? && self.class.class_variable_defined?(#{cv_str}) val end RUBY end end end # cattr_accessor is an alias for mattr_accessor in Rails; route it too. if method_defined?(:cattr_accessor, true) alias_method :_unshimmed_cattr_accessor, :cattr_accessor def cattr_accessor(*args, **kwargs, &block) mattr_accessor(*args, **kwargs, &block) end end }) end |
.install_rails_load_hook ⇒ Object
Defer the Rails-module patch until Rails is defined. A TracePoint on
:class fires when rails.rb opens module Rails (module bodies fire
as :class); once the constant is assigned, we patch once and disable
the hook.
39 40 41 42 43 44 45 46 47 48 49 50 |
# File 'lib/ractor_rails_shim/patches/rails_module.rb', line 39 def install_rails_load_hook return if @rails_load_hook_installed @rails_load_hook_installed = true @rails_tp = TracePoint.new(:class) do |trace| if defined?(::Rails) && !@rails_module_patched @rails_tp.disable patch_rails_module!(::Rails) end end @rails_tp.enable end |
.install_rails_module ⇒ Object
Patch the Rails module's class-level accessors (Rails.application, Rails.env, Rails.cache, etc.) to route through IsolatedExecutionState. Defers via a load hook if Rails isn't defined yet (the config/boot.rb case).
26 27 28 29 30 31 32 33 |
# File 'lib/ractor_rails_shim/patches/rails_module.rb', line 26 def install_rails_module _register_patch :rails_module, "8.1" if defined?(::Rails) patch_rails_module!(::Rails) else install_rails_load_hook end end |
.install_route_helpers_patch ⇒ Object
30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/ractor_rails_shim/patches/route_helpers.rb', line 30 def install_route_helpers_patch return if @route_helpers_patched @route_helpers_patched = true _register_patch :route_helpers, "8.1" return unless defined?(::ActionDispatch::Routing::RouteSet) rs = ::ActionDispatch::Routing::RouteSet # 1. Replace the PATH / UNKNOWN lambdas with shareable Method objects. begin rs.send(:remove_const, :PATH) if rs.const_defined?(:PATH, false) rescue end rs.const_set(:PATH, ::ActionDispatch::Http::URL.method(:path_for)) rescue nil begin rs.send(:remove_const, :UNKNOWN) if rs.const_defined?(:UNKNOWN, false) rescue end rs.const_set(:UNKNOWN, ::ActionDispatch::Http::URL.method(:url_for)) rescue nil # 2. Named route helpers -> compiled `def` (regular `resources` routes). rs.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def define_url_helper(mod, name, helper, url_strategy) const_name = :"RRS_HELPER_\#{name}" mod.const_set(const_name, helper) strategy = url_strategy.equal?(PATH) ? :PATH : :UNKNOWN body = "def " + name.to_s + "(*args)\\n" \ " last = args.last\\n" \ " options = case last\\n" \ " when Hash then args.pop\\n" \ " when ActionController::Parameters then args.pop.to_h\\n" \ " end\\n" \ " ::ActionDispatch::Routing::RouteSet.const_get(" + const_name.inspect + ").call(self, " + name.inspect + ", args, options, ::ActionDispatch::Routing::RouteSet.const_get(" + strategy.inspect + "))\\n" \ "end" mod.module_eval(body, __FILE__, __LINE__ + 1) end RUBY # 3. `direct` / `resolve` helpers -> compiled `def`. if defined?(::ActionDispatch::Routing::RouteSet::NamedRouteCollection) ::ActionDispatch::Routing::RouteSet::NamedRouteCollection.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def add_url_helper(name, defaults, &block) helper = CustomUrlHelper.new(name, defaults, &block) path_name = :"\#{name}_path" url_name = :"\#{name}_url" @path_helpers_module.const_set(:"RRS_HELPER_\#{path_name}", helper) @url_helpers_module.const_set(:"RRS_HELPER_\#{url_name}", helper) pbody = "def " + path_name.to_s + "(*args)\\n const_get(:\\\"RRS_HELPER_" + path_name.to_s + "\\\").call(self, args, true)\\nend" ubody = "def " + url_name.to_s + "(*args)\\n const_get(:\\\"RRS_HELPER_" + url_name.to_s + "\\\").call(self, args, false)\\nend" @path_helpers_module.module_eval(pbody, __FILE__, __LINE__ + 1) @url_helpers_module.module_eval(ubody, __FILE__, __LINE__ + 1) @path_helpers << path_name @url_helpers << url_name self end RUBY end # 4. `_routes` / `_generate_paths_by_default` -> compiled `def`. rs.class_eval <<-RUBY, __FILE__, __LINE__ + 1 alias_method :_rrs_orig_generate_url_helpers, :generate_url_helpers def generate_url_helpers(supports_path) routes = self mod = _rrs_orig_generate_url_helpers(supports_path) mod.module_eval("def _routes\n @_routes || ::Rails.application.routes\nend", __FILE__, __LINE__ + 1) mod.module_eval("def _generate_paths_by_default\n " + supports_path.inspect + "\nend", __FILE__, __LINE__ + 1) mod end RUBY end |
.install_rubygems ⇒ Object
Installed at install (boot) time. Only redefines the reader; the
snapshot is filled in later by snapshot_gem_paths!.
21 22 23 24 25 26 27 |
# File 'lib/ractor_rails_shim/patches/rubygems.rb', line 21 def install_rubygems return if @rubygems_patched @rubygems_patched = true _register_patch :rubygems, "all" return unless defined?(::Gem) patch_rubygems! end |
.install_shareable_constants ⇒ Object
214 215 216 217 218 219 220 221 222 223 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 214 def install_shareable_constants # Called at install time; if ActiveSupport isn't loaded yet, the # constants don't exist. We re-run from patch_rails_module! (which # fires once Rails — and thus ActiveSupport — is defined). Guarded # by @shareable_constants_done so both paths are safe. _register_patch :shareable_constants, "8.1" return unless defined?(::ActiveSupport) do_install_shareable_constants end |
.install_url_helpers_patch ⇒ Object
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/ractor_rails_shim/patches/url_helpers.rb', line 27 def install_url_helpers_patch return if @url_helpers_patched @url_helpers_patched = true _register_patch :url_helpers, "8.1" return unless defined?(::ActiveRecord::Base) if defined?(::ActionView::RoutingUrlFor) ::ActionView::RoutingUrlFor.module_eval <<-RUBY, __FILE__, __LINE__ + 1 alias_method :_rrs_orig_view_url_for, :url_for def url_for(options = nil) _rrs_orig_view_url_for(options) rescue => e raise unless e.message.include?("un-shareable Proc") if options.is_a?(Hash) || options.is_a?(ActionController::Parameters) full_url_for(options) else meth = _generate_paths_by_default ? :path : :url builder = ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.public_send(meth) builder.handle_model_call(self, options) end end RUBY end if defined?(::ActionDispatch::Routing::UrlFor) ::ActionDispatch::Routing::UrlFor.module_eval <<-RUBY, __FILE__, __LINE__ + 1 alias_method :_rrs_orig_full_url_for, :full_url_for def full_url_for(options = nil) _rrs_orig_full_url_for(options) rescue => e raise unless e.message.include?("un-shareable Proc") if options.is_a?(Hash) || options.is_a?(ActionController::Parameters) route_name = options.delete :use_route merged = options.to_h.symbolize_keys.reverse_merge!(url_options) # `self._routes` is an un-shareable `define_method` block in a # worker Ractor, so calling it re-raises the same error. Route the # fallback through the worker-safe shareable RouteSet instead. if RactorRailsShim.const_defined?(:SHAREABLE_ROUTES) RactorRailsShim::SHAREABLE_ROUTES.url_for(merged, route_name) else _routes.url_for(merged, route_name) end else builder = ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.url builder.handle_model_call(self, options) end end RUBY end end |
.install_zeitwerk_registry ⇒ Object
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
# File 'lib/ractor_rails_shim/patches/zeitwerk_registry.rb', line 17 def install_zeitwerk_registry return if @zeitwerk_patched @zeitwerk_patched = true _register_patch :zeitwerk_registry, "8.1" if defined?(::Zeitwerk::Registry) patch_zeitwerk_registry! else # Defer until Zeitwerk loads. A TracePoint(:class) fires when # `module Registry` opens. One-shot. @zw_tp = TracePoint.new(:class) do |trace| if defined?(::Zeitwerk::Registry) && !@zeitwerk_registry_patched @zw_tp.disable patch_zeitwerk_registry! end end @zw_tp.enable end end |
.installed? ⇒ Boolean
202 203 204 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 202 def installed? @installed ||= false end |
.make_app_shareable!(app = Rails.application) ⇒ Object
Public API: make Rails.application shareable across Ractors. Replaces
every self-capturing Proc in the app graph with a callable object (no
captured binding), every Mutex/Monitor with a NoOpLock, and every
Concurrent::Map with a frozen Hash, then calls Ractor.make_shareable.
After this, Ractor.new(app) { |a| a.call(env) } works from worker
Ractors. Must run in the main Ractor after prepare_for_ractors! and
before spawning workers.
WARNING: this MUTATES the app object graph in place (replaces ivars). The app becomes read-only (frozen). Do NOT call if you intend to keep mutating the app (e.g. development reloading). Production-only.
Returns the shareable app. Raises on failure (e.g. if a Proc can't be replaced — add the missing constant to shareable_constants first).
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 45 def make_app_shareable!(app = Rails.application) # Shareable constants + Rack::Request + Inflector + ParameterEncoding + # PathRegistry + AbstractController + error_reporter + LookupContext + # I18n + Template::Handlers + ExecutionContext + Request param parsers. do_install_shareable_constants unless @shareable_constants_done # Install (or re-run, idempotently) the full framework-patch set. Most # are already applied by prepare_for_ractors!; this guarantees every # patch is present after full boot even if prepare_for_ractors! ran # before some classes were loaded. _install_all_framework_patches # Pre-compute lazy ivars BEFORE freezing (they mutate the app). _precompute_lazy_ivars(app) _precompute_propshaft!(app) # Force ActiveRecord attribute-method generation in the MAIN Ractor for # every loaded model. AR defines these lazily on first instantiation; if # left undone, a worker Ractor's first `Post.new` / record load re-enters # `define_attribute_methods`, which locks # `GeneratedAttributeMethods::LOCK` — a `Monitor` created in the main # Ractor and therefore non-shareable — raising Ractor::IsolationError. # Generating here (where the Monitor is reachable) sets # `@attribute_methods_generated = true` on the shared, frozen classes so # workers skip the lock entirely. _generate_ar_attribute_methods! # Warm + freeze ActiveModel's per-class `attribute_method_patterns_cache` # (and `attribute_method_matchers`) in MAIN for every loaded model. See # `_warm_attribute_method_patterns!` for why: a worker Ractor reading these # lazy class ivars (Array of [Regexp, Symbol], but mutable => unshareable) # during `redirect_to @post` -> `respond_to?` raises Ractor::IsolationError. _warm_attribute_method_patterns! # Capture each controller's OWN declared `process_action` symbol filters # (before_action / after_action) into a shareable table so worker # Ractors can replay them. The shim routes class_attribute-backed # `__callbacks` through IES and seeds workers with the empty default, so # controller filters do NOT run in workers by default (see # execution_wrapper.rb run_callbacks patch). For GET requests that depend # on a before_action (e.g. `set_post` loading `@post`), that breaks # rendering. We freeze the declared-filter table captured during eager # load (in main, before freeze) and the patched run_callbacks replays # them per controller. _freeze_declared_callbacks! # Warm + cache the routes' @ast / @simulator on the live graph. This MUST # run AFTER the route precompute above (which reloads/resets the routes) # and BEFORE _replace_unshareable_procs! / Ractor.make_shareable below: # the proc-replacement pass rewrites the Route constraint Procs held in # the simulator's @memos, and the freeze then shares the whole thing so # worker Ractors read the cached, frozen simulator via the original # Routes#simulator (no per-worker rebuild). See action_dispatch.rb. _freeze_shareable_class_ivars! _warm_journey_routes! # Neutralize the app's logger IO so Ractor.make_shareable doesn't freeze # $stdout/$stderr (freezing STDOUT breaks the process's own output). # Workers build their own per-Ractor Rails.logger, so the app-instance # logger is unused post-freeze; redirect its logdev to a fresh StringIO # sink (which is safely freezable). _neutralize_logger_io!(app) _replace_unshareable_procs!(app) _replace_locks_and_concurrent_maps!(app) Ractor.make_shareable(app) # Stash the now-shareable app in a constant so worker Ractors can read # `Rails.application` (e.g. Propshaft::Helper reads # `Rails.application.assets`, and various gems call Rails.application # internally). The shared app is frozen (read-only), so returning it # from worker Ractors is safe — they only read from it, never mutate. if Ractor.main? verbose, $VERBOSE = $VERBOSE, nil begin const_set(:SHAREABLE_APP, app) unless const_defined?(:SHAREABLE_APP) ensure $VERBOSE = verbose end end # Build the framework-config fallback AFTER the app is frozen. The # fallback makes class_attribute / mattr_accessor values shareable; some # of those values reference the app graph (e.g. config objects that point # back at Rails.application). Doing this after the app is already # shareable means Ractor.make_shareable on the config values is a no-op # for the app portion (already frozen) — avoiding a "can't modify frozen # app" error when precompute wrote to it. (prepare_for_ractors!, which # also builds the fallback, is a no-op now via @fallback_built.) _build_shareable_fallback! app end |
.make_constant_shareable(const_path) ⇒ Object
Resolve a constant path string to a value, and if it exists and is not already shareable, replace it with its shareable (deep-frozen) version. Returns true if the constant was made shareable (or already was); false if it doesn't exist yet (caller may retry).
242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 242 def make_constant_shareable(const_path) owner, name = split_const_path(const_path) return false unless owner && name return true if owner.const_defined?(name, false) == false val = owner.const_get(name, false) return true if Ractor.shareable?(val) shareable = _make_value_shareable(val) return true unless shareable # Deep-freeze and reassign. Ractor.make_shareable mutates `val` in # place (freezing it and its reachable objects) and returns it. # const_set warns "already initialized constant" because Rails' # environment_inquirer.rb defined the constant first. The reassign is # intentional (we're replacing the mutable value with its frozen # shareable twin), so silence that one warning. verbose, $VERBOSE = $VERBOSE, nil begin owner.const_set(name, shareable) ensure $VERBOSE = verbose end true end |
.patch_class_attribute! ⇒ Object
The actual patch. Idempotent. Must run in the main Ractor.
redefine is a singleton method on ClassAttribute (defined in
class << self), so we prepend onto the singleton class.
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 |
# File 'lib/ractor_rails_shim/patches/class_attribute.rb', line 45 def patch_class_attribute! return if @ca_patched @ca_patched = true ::ActiveSupport::ClassAttribute.singleton_class.prepend(Module.new { # redefine is called once per attribute at class_attribute-definition # time (in the main Ractor). The original defines methods with blocks; # we replace with string-eval'd methods that route through IES so # they're callable from any Ractor. The default value is seeded into # the main Ractor's IES slot immediately (matching original semantics # where the reader returns the default until a subclass overrides). def redefine(owner, name, namespaced_name, value) key = :"ractor_rails_shim_class_attr_#{owner.object_id}_#{namespaced_name}" key_str = key.inspect # Seed the main Ractor's IES slot with the default. Only seed in # main — workers start nil and set their own value via the writer. ActiveSupport::IsolatedExecutionState[key] = value if Ractor.main? # Also store in CLASS_ATTR_VALUES so the reader can fall back to it # in the MAIN ractor on non-boot threads. IES is thread-local: Puma's # request threads have empty IES slots, so the reader returns nil # without this fallback. This is the bug that breaks normal (non- # Ractor) multi-threaded servers — the minimal --minimal app didn't # hit it because /up doesn't trigger LogSubscriber.log_levels. # CLASS_ATTR_VALUES is NOT shareable (values may be mutable); only # safe to read from the main ractor. RactorRailsShim::CLASS_ATTR_VALUES[key] = value # Register so _build_shareable_fallback! can capture + make shareable # at prepare_for_ractors! time. owner.name may be nil for anonymous # classes (e.g. spec fixtures); use a stable label in that case. # The default value is stored too so the fallback builder can use it # when the live value can't be made shareable (e.g. __callbacks holds # self-capturing Procs — workers get the empty default, treating # boot-time callbacks as already-run, which is correct for a frozen # shared app). owner_label = owner.respond_to?(:name) ? owner.name : owner.class.name owner_label = owner_label || "anon_#{owner.class.name}_#{owner.object_id}" RactorRailsShim::CLASS_ATTRIBUTES << [owner_label, namespaced_name, key, value] # Always define the namespaced reader/writer on owner's singleton # class via string eval (no captured binding). The class_attribute # macro itself also defines `def #{name}; #{namespaced_name}; end` # via class_eval (string-eval'd, safe) on the owner — that calls our # IES-routed namespaced reader/writer. We override BOTH the namespaced # and (when owner is a module's singleton) the public name. # # Worker-Ractor fallback: when the worker's own IES slot is empty # (which it is by default — the value lives in main's slot), fall # back to the frozen shareable table built at prepare_for_ractors! # time. This is read-only and shared across all workers; workers that # need their own mutable value call the writer, which writes their # IES slot and shadows the fallback. target = owner.singleton_class? ? owner : owner.singleton_class if RactorRailsShim.thread_mode? # Thread (Puma/Falcon) mode: route through a SHARED (process-wide) # store keyed by the actual class's object_id, walking ancestors # for copy-on-write fallback. This restores per-subclass isolation # (lost by the IES-routed variant) without thread-local IES, which # is empty on Puma's request threads. target.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{namespaced_name} self.ancestors.each do |anc| v = RactorRailsShim::CLASS_ATTR_VALUES[:"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}"] return v unless v.nil? end #{namespaced_name.inspect} == :__callbacks ? {} : nil end def #{namespaced_name}=(new_value) RactorRailsShim::CLASS_ATTR_VALUES[:"ractor_rails_shim_class_attr_\#{self.object_id}_#{namespaced_name}"] = new_value new_value end RUBY # When owner is a module's singleton class, also override the # public reader `def #{name}` with the shared-store version. if owner.singleton_class? && owner.attached_object.is_a?(Module) owner.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{name} self.ancestors.each do |anc| v = RactorRailsShim::CLASS_ATTR_VALUES[:"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}"] return v unless v.nil? end #{namespaced_name.inspect} == :__callbacks ? {} : nil end def #{name}=(new_value) RactorRailsShim::CLASS_ATTR_VALUES[:"ractor_rails_shim_class_attr_\#{self.object_id}_#{namespaced_name}"] = new_value new_value end RUBY end else target.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{namespaced_name} self.ancestors.each do |anc| k = :"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}" v = ActiveSupport::IsolatedExecutionState[k] return v unless v.nil? fb = RactorRailsShim::SHAREABLE_FALLBACK[k] return fb unless fb.nil? end RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] if Ractor.main? end def #{namespaced_name}=(new_value) ActiveSupport::IsolatedExecutionState[#{key_str}] = new_value RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] = new_value if Ractor.main? new_value end RUBY # When owner is a module's singleton class, the original also # defines a public reader `def #{name} { value }` on owner directly # (block-based). Override it with the IES-routed version + fallback. if owner.singleton_class? && owner.attached_object.is_a?(Module) owner.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{name} self.ancestors.each do |anc| k = :"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}" v = ActiveSupport::IsolatedExecutionState[k] return v unless v.nil? fb = RactorRailsShim::SHAREABLE_FALLBACK[k] return fb unless fb.nil? end RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] if Ractor.main? end def #{name}=(new_value) ActiveSupport::IsolatedExecutionState[#{key_str}] = new_value RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] = new_value if Ractor.main? new_value end RUBY end end end # redefine_method is used by `redefine` internally and by other call # sites (rare). The class_attribute path goes through our `redefine` # above; keep the original block-based behavior for any other callers # so we don't break unrelated code. def redefine_method(owner, name, private: false, &block) super end }) end |
.patch_execution_wrapper! ⇒ Object
34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
# File 'lib/ractor_rails_shim/patches/execution_wrapper.rb', line 34 def patch_execution_wrapper! return if @exec_wrapper_registry_patched @exec_wrapper_registry_patched = true ew = ::ActiveSupport::ExecutionWrapper key = :ractor_rails_shim_exec_wrapper_active_key key_str = key.inspect # active_key returns :"active_execution_wrapper_<object_id>"; a frozen # Symbol is shareable. Compute it once per Ractor and cache in IES. ew.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def active_key v = ActiveSupport::IsolatedExecutionState[#{key_str}] return v unless v.nil? sym = :"active_execution_wrapper_\#{object_id}" ActiveSupport::IsolatedExecutionState[#{key_str}] = sym sym end RUBY # Patch ActiveSupport::Callbacks#run_callbacks to tolerate a nil # __callbacks (the case in worker Ractors whose class_attribute fallback # couldn't be made shareable because callback chains hold frozen, # self-capturing Procs). For a frozen, read-only shared app the boot-time # callbacks (ExecutionContext push/pop, CurrentAttributes clear) already # ran in the main Ractor at boot; worker Ractors don't need to re-run # them per request (CurrentAttributes/ExecutionContext are thread-local, # hence per-Ractor, and start empty in a fresh worker). When __callbacks # is nil, run_callbacks just yields the block — matching the empty-chain # fast path in the original. (Method body lives in active_support.rb.) _install_callbacks_nil_safe_patch # Patch ActiveSupport::Notifications.notifier to not read the @notifier # class ivar from a worker Ractor. The original is `attr_accessor # :notifier` with `@notifier = Fanout.new` set at module load — a raw # class ivar holding a Fanout (which has a Mutex + subscriber Procs, # both unshareable). Workers get their own per-Ractor Fanout (no # subscribers — instrumentation is a no-op in workers, which is correct # for a read-only shared app where log subscribers already ran in main). # `notifier` is read by `instrumenter` (per-request via Rails::Rack::Logger). # (Method body lives in active_support.rb.) _install_notifications_notifier_patch end |
.patch_rails_module!(mod) ⇒ Object
The actual Rails-module patch. Idempotent. Must be called from the main Ractor (it prepends onto Rails.singleton_class).
54 55 56 57 58 59 |
# File 'lib/ractor_rails_shim/patches/rails_module.rb', line 54 def patch_rails_module!(mod) return if @rails_module_patched @rails_module_patched = true do_install_shareable_constants _patch_rails_module_body(mod) end |
.patch_rubygems! ⇒ Object
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
# File 'lib/ractor_rails_shim/patches/rubygems.rb', line 29 def patch_rubygems! return if @rubygems_method_patched @rubygems_method_patched = true gem = ::Gem unless gem.singleton_class.method_defined?(:__shim_original_gem_paths) gem.singleton_class.alias_method :__shim_original_gem_paths, :paths end # `def` (not `define_method`) so the method has no captured binding and # is callable from any Ractor. `Ractor.main?` + a shareable constant are # both Ractor-safe. gem.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def paths if Ractor.main? __shim_original_gem_paths else ::RactorRailsShim::GEM_PATHS_SNAPSHOT end end RUBY end |
.patch_zeitwerk_registry! ⇒ Object
36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/ractor_rails_shim/patches/zeitwerk_registry.rb', line 36 def patch_zeitwerk_registry! return if @zeitwerk_registry_patched @zeitwerk_registry_patched = true reg = ::Zeitwerk::Registry # The ivars Zeitwerk sets at the bottom of registry.rb. Map each to an # IES key and a default-builder string (eval'd in the reader when the # Ractor's slot is empty). Builders reference Zeitwerk constants by # full path so they're resolvable from any Ractor. ivars = { loaders: [:ractor_rails_shim_zw_loaders, "Zeitwerk::Registry::Loaders.new"], gem_loaders_by_root_file: [:ractor_rails_shim_zw_gem, "{}"], autoloads: [:ractor_rails_shim_zw_autoloads, "Zeitwerk::Registry::Autoloads.new"], explicit_namespaces: [:ractor_rails_shim_zw_explicit, "Zeitwerk::Registry::ExplicitNamespaces.new"], inceptions: [:ractor_rails_shim_zw_inceptions, "Zeitwerk::Registry::Inceptions.new"], mutex: [:ractor_rails_shim_zw_mutex, "Mutex.new"], } # Redefine each reader (and the mutex, which is read directly as @mutex) # to route through IES with lazy per-Ractor init. Use a PREPENDED module # (not direct module_eval on the singleton class) because Zeitwerk's # `attr_reader :loaders` etc. run LATER in the module body and would # clobber a direct redefinition — same load-order issue as the Rails # module accessors. A prepended module stays in front of the lookup chain. # # In the MAIN ractor, fall back to the existing ivar (set by Zeitwerk at # the bottom of registry.rb) so main-ractor state is preserved. Worker # ractors lazily build their own via the builder string. reader_patch = Module.new ivars.each do |ivar, (key, builder)| key_str = key.inspect ivar_sym = :"@#{ivar}" ivar_str = ivar_sym.inspect reader_patch.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{ivar} v = ActiveSupport::IsolatedExecutionState[#{key_str}] return v unless v.nil? if Ractor.main? existing = instance_variable_get(#{ivar_str}) if instance_variable_defined?(#{ivar_str}) if existing ActiveSupport::IsolatedExecutionState[#{key_str}] = existing return existing end end v = #{builder} ActiveSupport::IsolatedExecutionState[#{key_str}] = v v end RUBY end reg.singleton_class.prepend(reader_patch) # `conflicting_root_dir?` and `loader_for_gem` read @mutex / @gem_loaders # directly via instance_variable_get-ish access (they use @mutex in the # method body). Since we redefined the readers, the direct @mutex refs # in those methods still hit the ivar. We need to rewrite those two # methods to call the reader instead. Easiest: prepend a module that # calls self.mutex / self.gem_loaders_by_root_file. reg.singleton_class.prepend(Module.new { def conflicting_root_dir?(loader, new_root_dir) mutex.synchronize do loaders.each do |existing_loader| next if existing_loader == loader existing_loader.__roots.each_key do |existing_root_dir| next if !new_root_dir.start_with?(existing_root_dir) && !existing_root_dir.start_with?(new_root_dir) new_root_dir_slash = new_root_dir + '/' existing_root_dir_slash = existing_root_dir + '/' next if !new_root_dir_slash.start_with?(existing_root_dir_slash) && !existing_root_dir_slash.start_with?(new_root_dir_slash) next if loader.__ignores?(existing_root_dir) break if existing_loader.__ignores?(new_root_dir) return existing_loader end end nil end end def loader_for_gem(root_file, namespace:, warn_on_extra_files:) h = gem_loaders_by_root_file h[root_file] ||= Zeitwerk::GemLoader.__new(root_file, namespace: namespace, warn_on_extra_files: warn_on_extra_files) end def unregister_loader(loader) gem_loaders_by_root_file.delete_if { |_, l| l == loader } end }) end |
.prepare_for_ractors! ⇒ Object
405 406 407 408 409 410 411 412 413 414 415 416 417 418 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 405 def prepare_for_ractors! do_install_shareable_constants RactorRailsShim._freeze_shareable_class_ivars! if RactorRailsShim.respond_to?(:_freeze_shareable_class_ivars!) snapshot_gem_paths! snapshot_query_logs! _install_all_framework_patches install_url_helpers_patch fix_url_helpers_singleton_routes _warm_active_record_class_caches! _freeze_active_record_class_ivars! _freeze_global_class_ivars! _freeze_global_constants! end |
.shareable_constants ⇒ Object
--- Generic constant-sharing utilities (moved from rails_module.rb) ----- These are framework-agnostic; SHAREABLE_CONSTANTS lives here too, so the whole constant-shareability machinery is owned by core.rb.
210 211 212 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 210 def shareable_constants SHAREABLE_CONSTANTS end |
.snapshot_gem_paths! ⇒ Object
Called from prepare_for_ractors! (post-boot, main Ractor) so the snapshot reflects Bundler-configured gem paths. Also called from install as a safety net so the constant is never undefined.
53 54 55 56 57 58 59 60 61 62 63 |
# File 'lib/ractor_rails_shim/patches/rubygems.rb', line 53 def snapshot_gem_paths! return unless defined?(::Gem) return if defined?(::RactorRailsShim::GEM_PATHS_SNAPSHOT) snap = ::Gem.paths begin Ractor.make_shareable(snap) rescue StandardError snap = Ractor.make_shareable(::Gem.path) end ::RactorRailsShim.const_set(:GEM_PATHS_SNAPSHOT, snap) end |
.snapshot_query_logs! ⇒ Object
Capture the QueryLogs handlers/formatter as a shareable snapshot for workers (called post-boot, main Ractor, in prepare_for_ractors!).
We deliberately do NOT use Ractor.make_shareable on the raw @handlers
objects: a handler may be a ZeroArityHandler wrapping a Proc, a raw
lambda/Proc tag, or an IdentityHandler whose value is unshareable — all of
which make_shareable raises on, which (the original rescue swallowed) left
QUERY_LOGS_SNAPSHOT unset and every worker falling through to the original
tag_content -> @handlers read -> Ractor::IsolationError. Instead we build a
fresh, guaranteed-shareable structure:
{ format: :legacy|:sqlcommenter,
handlers: [[key, :get_key, nil] | [key, :identity, value], ...] }
Only GetKeyHandler (context key lookup) and IdentityHandler (constant value, when that value itself is shareable) are captured. Proc/lambda handlers can't be expressed cross-Ractor and are dropped in workers (tags that depend on per-request Procs simply don't appear in worker query comments — acceptable; the main Ractor still logs them).
1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1601 def snapshot_query_logs! return unless defined?(::ActiveRecord::QueryLogs) return if RactorRailsShim.const_defined?(:QUERY_LOGS_SNAPSHOT) return unless Ractor.main? begin ql = ::ActiveRecord::QueryLogs format = ql. format = :legacy if format == false || format.nil? raw_handlers = ql.instance_variable_get(:@handlers) || [] entries = [] raw_handlers.each do |key, handler| if handler.is_a?(::ActiveRecord::QueryLogs::GetKeyHandler) entries << [key, :get_key, nil] elsif handler.is_a?(::ActiveRecord::QueryLogs::IdentityHandler) value = handler.instance_variable_get(:@value) next unless Ractor.shareable?(value) entries << [key, :identity, value] else # ZeroArityHandler (wraps a Proc) or a raw Proc/lambda tag: # intrinsically unshareable / depends on a closure; skip in workers. next end end snap = { format: format, handlers: entries.freeze }.freeze Ractor.make_shareable(snap) RactorRailsShim.const_set(:QUERY_LOGS_SNAPSHOT, snap) rescue StandardError nil end end |
.split_const_path(path) ⇒ Object
Split "A::B::C" into [A::B (module), :C]. Returns [nil, nil] if the parent isn't defined.
296 297 298 299 300 301 302 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 296 def split_const_path(path) parts = path.split("::") return [Object, parts.first.to_sym] if parts.size == 1 parent = parts[0...-1].inject(Object) { |ns, n| ns.const_get(n) } rescue nil return [nil, nil] unless parent [parent, parts.last.to_sym] end |
.thread_mode? ⇒ Boolean
Install all the patches. Safe to call multiple times (idempotent).
May be called either before or after Rails is loaded:
- If Rails is already defined (e.g. `Bundler.require` ran first), the
Rails module accessors are patched immediately.
- If Rails is not yet defined (the normal `config/boot.rb` case, where
`install` is called before `require "rails"`), a one-shot load hook
defers the Rails-module patch until `rails.rb` is loaded. The
`mattr_accessor` macro patch (a `Module.prepend`) applies
immediately regardless, because it patches the macro itself, not
any Rails constant.
True when the shim should install its THREAD-server (Puma/Falcon) mode instead of the default Ractor (kino) mode. In thread mode Ractor.main? is true, so Rails' own globals (class variables / class ivars) are thread-safe and used as-is; only the class_attribute callback-chain isolation fix and the nil-safe callback replay are installed. The other patches route framework globals through per-Ractor IsolatedExecutionState, which is empty on Puma's request threads and would break the app, so they are skipped.
Set explicitly via RactorRailsShim.thread_mode = true, or implicitly from ENV (puma|falcon|thin|webrick|thread*). Detected in install.
148 149 150 151 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 148 def thread_mode? return @thread_mode if defined?(@thread_mode) false end |
.worker_app(frozen_app) ⇒ Object
Build the shareable Rack app handed to kino. Captures the application's constants in the main Ractor and wraps the frozen, shareable app in a WorkerApp that rebinds those constants (and initializes the worker's ActiveRecord connection) on the first request served by each worker Ractor. Returns a shareable WorkerApp instance.
535 536 537 538 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 535 def worker_app(frozen_app) bindings = capture_app_constants WorkerApp.new(frozen_app, bindings) end |
.worker_ar_init(app) ⇒ Object
Wrap app so every worker Ractor initializes its ActiveRecord
connections on first request. Returns a shareable wrapper.
1535 1536 1537 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1535 def worker_ar_init(app) ArWorkerInitWrapper.new(app) end |