Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ jobs:
gemfile: gemfiles/Gemfile.rails-8.0.x
- ruby_version: jruby
gemfile: gemfiles/Gemfile.rails-main
# Rails main now requires Ruby >= 3.3 (activesupport)
- ruby_version: 3.2
gemfile: gemfiles/Gemfile.rails-main
include:
- ruby_version: 3.4
gemfile: Gemfile
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ We support Rails versions from 6.0 and up.

### Ruby (without Rails)

We support Ruby versions from 3.0 and up.
We support Ruby versions from 3.2 and up.

If you want to use this library without Rails, you can simply add `i18n` to your `Gemfile`:

Expand Down Expand Up @@ -54,6 +54,18 @@ I18n.locale = :de
I18n.t(:test) # => "Dies ist ein Test"
```

### Fiber-safe config updates

`Fiber[]` values are inherited by child fibers.
To avoid sharing mutable config objects across fibers, prefer creating updated configs and setting them with `set!`:

```ruby
I18n.config.with(locale: :ja).set!
```

`set!` stores a frozen config in the current execution context.
This keeps inherited state safe and lets updates happen by replacing config objects.

## Features

* Translation and localization
Expand Down
24 changes: 20 additions & 4 deletions lib/i18n.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,29 @@ def self.reserved_keys_pattern # :nodoc:
module Base
# Gets I18n configuration object.
def config
Thread.current.thread_variable_get(:i18n_config) ||
Thread.current.thread_variable_set(:i18n_config, I18n::Config.new)
current = Fiber[:i18n_config] || self.config = I18n::Config.new
if current.respond_to?(:owned_by?) && !current.owned_by?(Fiber.current)
current = current.dup
Fiber[:i18n_config] = current
end

current
end

# Gets a mutable I18n configuration object.
def writable_config
current = config
return current unless current.frozen?

current = current.dup
Fiber[:i18n_config] = current
current
end

# Sets I18n configuration object.
def config=(value)
Thread.current.thread_variable_set(:i18n_config, value)
Fiber[:i18n_config] = value
value.owner = Fiber.current if value.respond_to?(:owner=) && !value.frozen?
end

# Write methods which delegates to the configuration object
Expand All @@ -73,7 +89,7 @@ def #{method}
end

def #{method}=(value)
config.#{method} = (value)
writable_config.#{method} = (value)
end
DELEGATORS
end
Expand Down
4 changes: 2 additions & 2 deletions lib/i18n/backend/fallbacks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ class << self
# Returns the current fallbacks implementation. Defaults to +I18n::Locale::Fallbacks+.
def fallbacks
@@fallbacks ||= I18n::Locale::Fallbacks.new
Thread.current[:i18n_fallbacks] || @@fallbacks
Fiber[:i18n_fallbacks] || @@fallbacks
end

# Sets the current fallbacks implementation. Use this to set a different fallbacks implementation.
def fallbacks=(fallbacks)
@@fallbacks = fallbacks.is_a?(Array) ? I18n::Locale::Fallbacks.new(fallbacks) : fallbacks
Thread.current[:i18n_fallbacks] = @@fallbacks
Fiber[:i18n_fallbacks] = @@fallbacks
end
end

Expand Down
36 changes: 35 additions & 1 deletion lib/i18n/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,41 @@ def locale
defined?(@locale) && @locale != nil ? @locale : default_locale
end

# Sets the current locale pseudo-globally, i.e. in the Thread.current hash.
def initialize
@owner = Fiber.current
end

def owned_by?(fiber)
@owner == fiber
end

def owner=(fiber)
@owner = fiber
end

def initialize_copy(other)
@owner = Fiber.current
end

# Returns a copied configuration with the provided attributes set.
def with(**attrs)
dup.tap do |copy|
attrs.each do |name, value|
copy.public_send("#{name}=", value)
end
end
end

# Sets this configuration as the current one for the active execution context.
# The stored configuration is frozen to avoid sharing mutable state between fibers.
def set!
self.owner = Fiber.current unless frozen?
freeze
I18n.config = self
self
end

# Sets the current locale pseudo-globally, i.e. in the Thread.current or Fiber local hash.
def locale=(locale)
I18n.enforce_available_locales!(locale)
@locale = locale && locale.to_sym
Expand Down
2 changes: 1 addition & 1 deletion lib/i18n/middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def initialize(app)
def call(env)
@app.call(env)
ensure
Thread.current.thread_variable_set(:i18n_config, I18n::Config.new)
Fiber[:i18n_config] = I18n::Config.new
end

end
Expand Down
4 changes: 2 additions & 2 deletions test/i18n/middleware_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ def setup
end

test "middleware initializes new config object after request" do
old_i18n_config_object_id = Thread.current.thread_variable_get(:i18n_config).object_id
old_i18n_config_object_id = Fiber[:i18n_config].object_id
@middleware.call({})

updated_i18n_config_object_id = Thread.current.thread_variable_get(:i18n_config).object_id
updated_i18n_config_object_id = Fiber[:i18n_config].object_id
refute_equal updated_i18n_config_object_id, old_i18n_config_object_id
end

Expand Down
85 changes: 81 additions & 4 deletions test/i18n_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ def setup
assert_equal I18n.default_locale, I18n.locale
end

test "sets the current locale to Thread.current" do
test "sets the current locale to Fiber local" do
assert_nothing_raised { I18n.locale = 'de' }
assert_equal :de, I18n.locale
assert_equal :de, Thread.current.thread_variable_get(:i18n_config).locale
assert_equal :de, Fiber[:i18n_config].locale
I18n.locale = :en
end

Expand All @@ -80,12 +80,42 @@ def setup
begin
I18n.config = self
assert_equal self, I18n.config
assert_equal self, Thread.current.thread_variable_get(:i18n_config)
assert_equal self, Fiber[:i18n_config]
ensure
I18n.config = ::I18n::Config.new
::I18n::Config.new.set!
end
end

test "config#with returns a copied configuration with overrides" do
base = I18n::Config.new
base.locale = :en

copy = base.with(locale: :de)

assert_equal :en, base.locale
assert_equal :de, copy.locale
refute_equal base.object_id, copy.object_id
end

test "config#with raises when an unknown attribute is passed" do
assert_raises(NoMethodError) { I18n::Config.new.with(unknown_attribute: true) }
end

test "config#set! stores a frozen config and keeps I18n setters usable" do
I18n.available_locales = [:en, :de]
config = I18n::Config.new.with(locale: :en)

assert_same config, config.set!
assert_same config, I18n.config
assert config.frozen?
assert_raises(FrozenError) { config.locale = :de }

I18n.locale = :de
refute_same config, I18n.config
assert_equal :en, config.locale
assert_equal :de, I18n.locale
end

test "locale is not shared between configurations" do
a = I18n::Config.new
b = I18n::Config.new
Expand Down Expand Up @@ -561,4 +591,51 @@ def call(exception, locale, key, options); key; end

assert_equal :en, fiber_locale
end

test "I18n.locale is isolated between concurrent Fibers" do
I18n.available_locales = [:en, :ja]
I18n.default_locale = :en
I18n.locale = :en

fiber_ja = Fiber.new do
I18n.locale = :ja
Fiber.yield I18n.locale
I18n.locale
end

fiber_en = Fiber.new do
I18n.locale = :en
Fiber.yield I18n.locale
I18n.locale
end

assert_equal :ja, fiber_ja.resume
assert_equal :en, fiber_en.resume
assert_equal :ja, fiber_ja.resume
assert_equal :en, fiber_en.resume
end

test "I18n.locale write in child Fiber does not leak to parent" do
I18n.available_locales = [:en, :ja]
I18n.default_locale = :en
I18n.locale = :en

parent_locale = I18n.locale
parent_config_id = I18n.config.object_id

child_saw_locale = nil
child_config_id = nil

Fiber.new do
child_saw_locale = I18n.locale
child_config_id = I18n.config.object_id

I18n.locale = :ja
assert_equal :ja, I18n.locale
end.resume

assert_equal parent_locale, I18n.locale
assert_equal :en, child_saw_locale
refute_equal parent_config_id, child_config_id
end
end