Introduction
Building reliable concurrent systems requires understanding how errors propagate across Ractor boundaries, how Ractors terminate, and what guarantees the lifecycle methods provide. Ruby 4.0 introduces a clear error hierarchy and strict rules about closing Ractors that differ significantly from thread error handling.
Key Concepts
Ractor::IsolationError: Raised when code violates Ractor isolation rules, such as accessing an outer-scope variable from inside a Ractor block.Ractor::MovedError: Raised when accessing an object that was moved to another Ractor viasend(obj, move: true).Ractor::ClosedError: Raised when sending to or receiving from a closed port.Ractor::UnsafeError: Raised when attempting to share an object that is not safe to share between Ractors.Ractor::RemoteError: Wraps an exception raised inside a Ractor; raised on the caller's side when callingjoinorvalueon a Ractor that terminated with an unhandled exception.Ractor#close: Closes the current Ractor. In Ruby 4.0,closeonly works when called onRactor.current.
Real World Context
In a pipeline of Ractors — say, a log processing system where one Ractor reads, another parses, and a third aggregates — any stage can fail. Without proper error handling, a crash in the parser Ractor silently drops all logs. Understanding RemoteError lets the aggregator detect the failure and either retry or alert operators.
Deep Dive
Error Propagation with RemoteError
When a Ractor's block raises an unhandled exception, it terminates. The exception is captured and re-raised as Ractor::RemoteError when the caller invokes join or value:
rubyfaulty = Ractor.new do raise ArgumentError, "invalid input: negative age" end begin faulty.join rescue Ractor::RemoteError => e puts e.message # => "thrown by remote Ractor" puts e.cause.class # => ArgumentError puts e.cause.message # => "invalid input: negative age" end
The original exception is available via e.cause, allowing the caller to inspect the actual error. This two-layer design keeps the error's origin clear.
IsolationError
Attempting to access outer-scope variables from a Ractor block raises IsolationError:
rubygreeting = "hello" begin Ractor.new { puts greeting } # Tries to capture 'greeting' rescue Ractor::IsolationError => e puts e.message # => can not access non-shareable objects from Ractor end # Fix: pass as argument Ractor.new(greeting) { |g| puts g } # Deep-copies greeting
This error is raised at Ractor creation time, not at runtime inside the block, making it easy to catch during development.
Closing Ractors and Ports
In Ruby 4.0, Ractor#close is restricted — it only works on the current Ractor:
rubyworker = Ractor.new do Ractor.receive # Wait for a message Ractor.current.close # Close self — OK end worker.send(:start) worker.join # Attempting to close a different Ractor raises an error # worker.close # => Error! Can only close Ractor.current
To stop a remote Ractor, close the port it reads from, which causes Ractor::ClosedError inside the Ractor:
rubytask_port = Ractor::Port.new worker = Ractor.new(task_port) do |port| loop { port.receive } rescue Ractor::ClosedError # Port was closed externally, shut down gracefully end task_port.close # Triggers ClosedError inside the worker worker.join
UnsafeError
Some objects are inherently unsafe to share or send. Attempting to do so raises UnsafeError:
rubyio = File.open("/tmp/test.txt", "w") begin Ractor.new(io) { |f| f.write("hello") } rescue Ractor::UnsafeError => e puts e.message # => can not pass IO objects between Ractors end
IO objects, Threads, and other resource handles cannot cross Ractor boundaries because they are tied to OS resources that cannot be safely shared.
Common Pitfalls
- Ignoring RemoteError — If you never call
joinorvalueon a Ractor, its exception is silently lost. Always join Ractors you care about. - Trying to close a remote Ractor directly — In Ruby 4.0,
closeonly works onRactor.current. Use port closing or a poison-pill message to signal shutdown instead.
Best Practices
- Always rescue RemoteError around
join/value— Treat it like rescuing exceptions aroundThread#value. Inspecte.causefor the real error. - Use port closing for clean shutdown — Close the input port to trigger
ClosedErrorin the worker's receive loop. This is the idiomatic Ruby 4.0 shutdown pattern. - Validate data before sending — Check
Ractor.shareable?(obj)before sharing, and avoid sending IO objects, Procs (unless shareable), or Thread references.
Summary
Ractor::RemoteErrorwraps exceptions from terminated Ractors; access the original viae.cause.IsolationErrorfires at Ractor creation when the block captures non-shareable outer variables.MovedErrorfires when accessing an object sent withmove: true.ClosedErrorfires when a closed port is used for send or receive.UnsafeErrorfires when trying to send un-sendable objects like IO handles.Ractor#closeonly works onRactor.current— use port closing to stop remote Ractors.
Code Examples
# Graceful error handling in a Ractor pipeline
result_port = Ractor::Port.new
worker = Ractor.new(result_port) do |port|
data = Ractor.receive
processed = data.map { |record| record[:amount] * 1.08 } # Add tax
port.send(processed)
rescue => e
port.send({ error: e.class.name, message: e.message })
end
worker.send([{ amount: 100 }, { amount: 200 }])
result = result_port.receive
if result.is_a?(Hash) && result[:error]
puts "Worker failed: #{result[:error]} — #{result[:message]}"
else
puts "Processed: #{result.inspect}" # => [108.0, 216.0]
end