Ruby 4.0 Introduces Ruby::Box for In-Process Isolation (Part 1)
· 6 min read
Ruby 4.0 introduces Ruby::Box, a feature that provides isolated namespaces within a single Ruby process. This solves a long-standing problem: monkey patches and global modifications from one gem affecting all other code in your application.
The Problem with Shared Namespaces
When you load a gem that modifies core classes, those changes affect everything in your Ruby process:
# Gem A adds a titleize method to String
class String
def titleize
split.map(&:capitalize).join(' ')
end
end
# Now EVERY piece of code in your process sees this method
# Including Gem B, which might have its own expectations
"hello world".titleize # => "Hello World"
This becomes problematic when:
- Two gems define conflicting methods on the same class
- A gem’s monkey patch breaks another library’s assumptions
- You want to test code in isolation from invasive patches
- You need to run multiple versions of a gem simultaneously
Before Ruby 4.0, the only solutions were separate Ruby processes (with IPC overhead) or containers (with even more overhead).
Ruby 4.0: Enter Ruby::Box
Ruby::Box creates isolated spaces where code runs with its own class definitions, constants, and global variables. Changes made inside a box stay inside that box.
# Enable with environment variable at startup
# RUBY_BOX=1 ruby my_script.rb
# Check if Boxing is available
Ruby::Box.enabled? # => true
# Create an isolated box
box = Ruby::Box.new
# Load code that patches String
box.eval <<~RUBY
class String
def shout
upcase + "!!!"
end
end
RUBY
# The patch exists only inside the box
box.eval('"hello".shout') # => "HELLO!!!"
# Outside the box, String is unchanged
"hello".shout # => NoMethodError: undefined method `shout'
Understanding Box Types
Ruby::Box operates with three types of boxes:
Root Box: Contains all built-in Ruby classes and modules. This is established before any user code runs and serves as the template for other boxes.
Main Box: Your application’s default execution context. It’s automatically created from the root box when the process starts. This is where your main script runs.
User Boxes: Custom boxes you create with Ruby::Box.new. Each is copied from the root box, giving it a clean slate of built-in classes without any modifications from the main box or other user boxes.
# Your script runs in the "main" box
Ruby::Box.current # => #<Ruby::Box main>
# Create isolated boxes
plugin_box = Ruby::Box.new
another_box = Ruby::Box.new
# Each box is independent
plugin_box.object_id != another_box.object_id # => true
The Ruby::Box API
The API is straightforward with just a few methods:
# Creation
box = Ruby::Box.new
# Loading code
box.require('some_library') # Respects box's $LOAD_PATH
box.require_relative('./my_file') # Relative to current file
box.load('script.rb') # Direct file execution
# Executing code
box.eval('1 + 1') # Execute Ruby code as string
# Inspection
Ruby::Box.current # Returns the currently executing box
Ruby::Box.enabled? # Check if Boxing is active
What Gets Isolated
Ruby::Box isolates several aspects of the Ruby runtime:
Classes and Constants: Reopening a built-in class in one box doesn’t affect other boxes.
box = Ruby::Box.new
box.eval <<~RUBY
class Array
def sum_squares
map { |n| n ** 2 }.sum
end
end
RUBY
box.eval('[1, 2, 3].sum_squares') # => 14
[1, 2, 3].sum_squares # => NoMethodError
Global Variables: Changes to globals stay within the box.
box = Ruby::Box.new
box.eval('$my_config = { debug: true }')
box.eval('$my_config') # => { debug: true }
$my_config # => nil
Top-Level Methods: Methods defined at the top level become private instance methods of Object within that box only.
box = Ruby::Box.new
box.eval <<~RUBY
def helper_method
"I'm only available in this box"
end
RUBY
box.eval('helper_method') # => "I'm only available in this box"
helper_method # => NoMethodError
Enabling Ruby::Box
Ruby::Box is disabled by default. Enable it by setting the RUBY_BOX environment variable before the Ruby process starts:
RUBY_BOX=1 ruby my_application.rb
Important: Setting
RUBY_BOXafter the process has started has no effect. The boxing infrastructure must be initialized during Ruby’s boot sequence, so the variable must be set before the Ruby process starts.
# This check should be at the top of your application
unless Ruby::Box.enabled?
warn "Ruby::Box is not enabled. Start with RUBY_BOX=1"
exit 1
end
Important Limitations
Before adopting Ruby::Box, be aware of these constraints:
Not a Security Sandbox: Ruby::Box provides namespace isolation, not security isolation. Code in a box can still access the filesystem, network, and system resources. Do not use it to run untrusted code.
Native Extensions: Installing gems with native extensions may fail when RUBY_BOX=1 is set. The workaround is to install gems without the flag, then run your application with it enabled.
# Install gems normally
bundle install
# Run with Boxing enabled
RUBY_BOX=1 bundle exec ruby app.rb
ActiveSupport Compatibility: Some parts of active_support/core_ext have compatibility issues with Ruby::Box. Load ActiveSupport in your main context before creating boxes if needed.
Experimental Status: This feature is experimental in Ruby 4.0. Behavior may change in future versions. The Ruby core team recommends experimentation but advises caution in production environments.
File Scope Execution
One important detail: Ruby::Box operates on a file-scope basis. Each .rb file executes entirely within a single box. Once loaded, all methods and procs defined in that file operate within their originating box, regardless of where they’re called from.
# helper.rb
def process(data)
# This method always runs in the box where helper.rb was loaded
data.transform
end
# main.rb
box = Ruby::Box.new
box.require_relative('helper')
# Even when called from main, process() runs in box's context
box.eval('process(my_data)')
Ruby::Box brings a long-requested capability to Ruby: proper namespace isolation without process boundaries. In Part 2, we’ll explore practical use cases including plugin systems, multi-tenant configurations, and strategies for gradual adoption.