Plugins
Two plugins that need incompatible versions of the same gem cannot load into one Ruby process. This month-end report runner loads both anyway, each in its own Carton. Source on GitHub.
The problem
Section titled “The problem”Each plugin sends the month’s orders to a service with Faraday, and each is stuck on a different major version:
| Plugin | Gemfile | Why it can’t move |
|---|---|---|
crm |
faraday ~> 1.10, through its vendored crm_client |
calls request :basic_auth, which 2.0 removed |
webhook |
faraday ~> 2.0 |
calls request :authorization, :basic, ..., which 2.0 added |
Plain Ruby activates one version of a gem per process. Load crm first and webhook fails:
Gem::LoadError: can't activate faraday-2.15.0, already activated faraday-1.10.6No single lockfile satisfies both, so Bundler can’t help. The usual fixes are to fork one plugin, run each in its own process, or wait for its maintainer to upgrade. crm also carries a legacy patch that prints every Float with two decimals, which in plain Ruby changes Float#to_s for the host and webhook too.
The host
Section titled “The host”main.rb finds each directory in plugins/, imports it as a Carton, and calls the plugin interface, title and call(orders):
require 'carton'
# A month-end report runner. It has no Gemfile and never loads Faraday; each# plugin brings its own bundle.
# The orders are frozen because objects are shared across Boxes: a plugin# could otherwise change them for the host and every other plugin.ORDERS = [ { month: '2026-06', state: 'CA', amount: 600.0 }, { month: '2026-06', state: 'NY', amount: 400.0 }, { month: '2026-07', state: 'CA', amount: 630.5 }, { month: '2026-07', state: 'TX', amount: 449.5 }, { month: '2026-08', state: 'NY', amount: 550.25 }, { month: '2026-08', state: 'TX', amount: 699.75 }, { month: '2026-09', state: 'CA', amount: 981.0 }, { month: '2026-09', state: 'NY', amount: 350.0 },].map(&:freeze).freeze
# The plugin interface. A plugin is a directory, plugins/<name>/, whose# Entrypoint lib/<name>.rb exports a module with `title` and `call(orders)`,# which sends the orders somewhere and returns report lines as Strings.PLUGIN_METHODS = %i[title call].freeze
plugin_dirs = Dir.glob('plugins/*/', base: __dir__).sort
plugins = plugin_dirs.map do |dir| # Carton boundary: each import loads one plugin, and its gems, in a new # Box. Only its Export comes back. The path is found at runtime, so mark # it for `carton check`. plugin = import_relative dir # carton:dynamic
missing = PLUGIN_METHODS.reject { plugin.respond_to?(it) } raise "#{dir} lacks #{missing.join(', ')}" if missing.any?
plugin end
plugins.each do |plugin| puts "== #{plugin.title} ==" puts plugin.call(ORDERS) putsend
# Neither plugin's gems nor crm's Float#to_s patch reached the host.puts '== Host =='puts "Faraday defined: #{!defined?(Faraday).nil?}"puts "0.1 + 0.2 = #{0.1 + 0.2}"main.rb # the host: finds, imports, and runs pluginsplugins/ crm/ lib/crm.rb # Entrypoint: imports crm_client, export Crm lib/crm/ float_as_money.rb # the Float#to_s patch, inside the Carton cartons/crm_client/ Gemfile, Gemfile.lock # faraday 1.10.6 lib/crm_client.rb # Entrypoint: setup_bundle, export CrmClient webhook/ Gemfile, Gemfile.lock # faraday 2.15.0 lib/webhook.rb # Entrypoint: setup_bundle, export WebhookThe host never loads Faraday and has no Gemfile. Adding a plugin is a new directory with its own Gemfile, or with Cartons that have one. Both plugins use Faraday’s test adapter, so the example runs offline.
The plugins
Section titled “The plugins”webhook calls Carton.setup_bundle in its Entrypoint, so it gets Faraday 2.15.0 in its own Box:
# Carton boundary: this Entrypoint and everything it loads run in the webhook# plugin's own Box. The host only gets the module exported below.
# Bundle boundary: plugins/webhook/Gemfile locks faraday 2.15.0. Bundler# activates it in this Box only, beside crm_client's 1.10.6.Carton.setup_bundlerequire 'faraday'
module Webhook URL = 'https://hooks.example.com'
# A stand-in for the real service, so the example runs offline. STUBS = Faraday::Adapter::Test::Stubs.new do |stub| stub.post('/month-end') do |env| months = JSON.parse(env.body).size [202, { 'Content-Type' => 'application/json' }, { months: }.to_json] end end private_constant :STUBS
def self.title = "Webhook (faraday #{Faraday::VERSION})"
def self.call(orders) totals = orders .group_by { it.fetch(:month) } .sort .to_h { |month, rows| [month, rows.sum { it.fetch(:amount) }] } response = connection.post('/month-end', totals)
[ *totals.map { |month, total| "#{month} #{total}" }, "POST /month-end #{response.status} #{response.body['months']} months", ] end
# `request :authorization, :basic, ...` is new in faraday 2, so this plugin # cannot run on the 1.10 that crm_client needs. def self.connection Faraday.new(URL) do |f| f.request :authorization, :basic, 'reports', 'secret' f.request :json f.response :json f.adapter :test, STUBS end end
private_class_method :connectionend
# Export: the plugin interface is this module's `title` and `call`.export Webhookcrm has no Gemfile. It imports its vendored API client, crm_client, which sets up its own Carton Bundle with Faraday 1.10.6. crm_client is a Transitive Carton: the host reaches it only through crm, and Faraday stays in crm_client’s Box:
# Carton boundary: this Entrypoint and everything it loads run in the crm# plugin's own Box. The host only gets the module exported below.
# A file inside this Carton: only this Entrypoint can load it, and the patch# it makes applies to this Box only.require_relative 'crm/float_as_money'
# Carton boundary: the vendored CRM client is a Carton of its own, so this# plugin needs no Gemfile. crm_client sets up its own bundle, and Faraday# stays in its Box.CrmClient = import_relative '../cartons/crm_client'
module Crm def self.title = "CRM (faraday #{CrmClient.faraday_version})"
def self.call(orders) orders .group_by { it.fetch(:state) } .sort .map do |state, rows| total = rows.sum { it.fetch(:amount) } # This Float prints with two decimals because of float_as_money.rb. "#{state} #{total} #{CrmClient.sync(state, total)}" end endend
# Export: the plugin interface is this module's `title` and `call`.export Crm# Carton boundary: crm's vendored API client. It is a Transitive Carton for the# host, which reaches it only through the crm plugin.
# Bundle boundary: crm_client/Gemfile locks faraday 1.10.6. Bundler activates# it in this Box only, so the crm plugin and the host never see it.Carton.setup_bundlerequire 'faraday'
module CrmClient URL = 'https://crm.example.com'
# A stand-in for the real service, so the example runs offline. STUBS = Faraday::Adapter::Test::Stubs.new do |stub| stub.put(%r{\A/accounts/\w+/sales\z}) { [200, {}, 'synced'] } end private_constant :STUBS
def self.faraday_version = Faraday::VERSION
def self.sync(account, total) connection.put("/accounts/#{account}/sales", total: total.to_s).body end
# Written against faraday 1. `request :basic_auth` was removed in 2.0, so # this client cannot move to the version the webhook plugin needs. def self.connection Faraday.new(URL) do |f| f.request :basic_auth, 'reports', 'secret' f.request :url_encoded f.adapter :test, STUBS end end
private_class_method :connectionend
# Export: the client's Public Surface is `faraday_version` and `sync`.export CrmClientThe Float#to_s patch stays in crm’s Box, so crm_client and webhook print Floats as Ruby does:
# A legacy patch that prints every Float as money. In plain Ruby this would# change Float#to_s for the whole process. Here it changes it only in the crm# Box, so the host, the webhook plugin, and crm_client keep Ruby's Float#to_s.class Float def to_s = format('%.2f', self)endbundle exec rake example:plugins # installs each bundle, then runsRUBY_BOX=1 ruby examples/plugins/main.rb== CRM (faraday 1.10.6) ==CA 2211.50 syncedNY 1300.25 syncedTX 1149.25 synced
== Webhook (faraday 2.15.0) ==2026-06 1000.02026-07 1080.02026-08 1250.02026-09 1331.0POST /month-end 202 4 months
== Host ==Faraday defined: false0.1 + 0.2 = 0.30000000000000004Sharing a Carton between plugins
Section titled “Sharing a Carton between plugins”A Carton loads once per process, keyed by its Entrypoint’s path. If both plugins import the same third Carton, such as a shared path gem, they get one copy, built with the gem versions its First Importer selected. When the other plugin’s Carton Bundle locks a different version of a gem that Carton uses, its import raises Carton::ImportError naming the gem and both versions, rather than sharing a copy built against the wrong one. Either lock the same versions in both plugins, or give each its own copy: a Carton at another path, such as crm’s vendored crm_client, is a separate Carton.
Testing plugins
Section titled “Testing plugins”Test a plugin the way the host uses it: import its directory and call its interface. test/webhook_test.rb does this with minitest from test/Gemfile, separate from the plugin’s Carton Bundle, so the host still needs no Gemfile:
RUBY_BOX=1 ruby examples/plugins/test/webhook_test.rbEach Carton loads once per process, so a plugin’s state carries from one test to the next. Keep plugins stateless, or run each plugin’s tests in its own process. To test a plugin’s Interior, require its files directly without Boxes; see Testing.
Limits
Section titled “Limits”- Box is not a security boundary. Plugins run with the host’s full privileges, so load only plugins you trust.
- Objects are shared, not copied. A plugin can mutate what the host passes it, so the host freezes
ORDERS. Pass plain values such as Strings, Numbers, and Hashes: each Box loads its own gem classes, so aFaraday::Responsefrom one plugin is not aFaraday::Responseto the host or the other plugin. - Box isolates patches to builtins such as
Float. Classes a plugin defines are shared, so reopening another plugin’s class changes it everywhere; see Sharing classes and services. ENVis process-wide.setup_bundlerestores it, but load plugins while booting, before starting threads that readENV.- Each Box costs tens of milliseconds to create, plus its own gem loading.
