Status: Ready for Implementation Date: 2025-12-26 Updated: After spike validation
Proctor transforms a long-lived external process into a Ractor-citizen with:
- Bidirectional messaging - stdin/stdout as send/receive
- Death notification - via
Ractor.monitor(discovered in spikes!) - Isolation - process crashes become messages, not Ruby crashes
Think of it as: "Erlang ports for Ruby 4.0"
# Vision
proctor = Umi::Proctor.new("redis-server", "--port", "6379")
# Bidirectional communication
proctor << "PING\r\n"
response = proctor.receive # => "PONG\r\n"
# Death notification (non-blocking)
proctor.on_exit { |status| log "Redis died: #{status}" }
# Or blocking wait
result = proctor.join # => Proctor::Result-
Ractor.monitor(port)- Ruby 4.0 provides death notification!- Sends
:exited(normal) or:aborted(crash) to specified port - Can be mixed with message ports in
Ractor.select
- Sends
-
Spawn INSIDE the Ractor - Process must be spawned internally
- Passing IO objects from main to Ractor hangs for bidirectional pipes
Open3.popen3works inside Ractors- Ractor that spawns the process "owns" it for
Process.wait2
-
Threads inside Ractors - Essential for async I/O
- stdout_thread, stderr_thread, death_thread pattern works
- Threads send to parent via
parent << [:event, data]
-
NO native timeout in
Ractor.select- Must use timer Ractor pattern- Spawn a Ractor that sleeps then sends
:timeout - Include timer_port in select
- Challenge: cleanup timer if message arrives first
- Spawn a Ractor that sleeps then sends
-
Port ownership - Only creator can
receive, anyone cansend
| Original Assumption | Reality |
|---|---|
| Pass pipes to Watcher Ractor | Spawn process INSIDE Watcher |
Ractor.select has timeout |
NO - use timer Ractor pattern |
| "No built-in process linking" | Ractor.monitor provides this! |
| Need to build death detection | Already exists via monitor |
┌─────────────────────────────────────────────────────────┐
│ User Code │
│ proctor = Proctor.new("cmd"); proctor << msg │
└─────────────────────────┬───────────────────────────────┘
│
┌─────────────────────────▼───────────────────────────────┐
│ Proctor (API object) │
│ - Holds reference to Watcher Ractor │
│ - Forwards commands: [:stdin, data], [:kill, sig] │
│ - Receives events: [:stdout, line], [:process_died] │
│ - Monitors Watcher with watcher.monitor(inbox) │
└─────────────────────────┬───────────────────────────────┘
│
┌─────────────────────────▼───────────────────────────────┐
│ Watcher Ractor (OWNS EVERYTHING) │
│ - Spawns process with Open3.popen3 internally │
│ - stdout_thread: reads stdout, sends to parent │
│ - stderr_thread: reads stderr, sends to parent │
│ - death_thread: wait_thr.value, sends to parent │
│ - Command loop: Ractor.receive → stdin.write │
└─────────────────────────┬───────────────────────────────┘
│
┌─────┴─────┐
│ OS Process │
│ (the cmd) │
└───────────┘
User Proctor Watcher Ractor OS Process
│ │ │ │
│── new(cmd) ───▶│ │ │
│ │── Ractor.new ───▶│ │
│ │ (cmd passed │── popen3(cmd) ──▶│
│ │ as arg) │ (spawns │
│ │ │ internally) │
│ │ │ │
│ │◀─ [:started,pid]─│ │
│ │ │ │
│── << msg ─────▶│ │ │
│ │── [:stdin,msg] ─▶│ │
│ │ │── stdin.write ──▶│
│ │ │ │
│ │ │◀── stdout ───────│
│ │◀─ [:stdout,line]─│ (via thread) │
│◀── receive ────│ │ │
│ │ │ │
│ │ │ (process dies)│
│ │◀─[:process_died]─│◀── wait_thr ─────│
│ │◀─ :exited ───────│ (via monitor) │
│◀── on_exit ────│ │ │
- Process MUST be spawned inside the Ractor (not passed FDs)
Ractor.monitor(port)provides death notificationRactor.selecthas NO timeout - use timer Ractor pattern- Port ownership: only creator can receive
- Threads inside Ractors work for async I/O
- External processes communicate via stdin/stdout/stderr
- They signal completion via exit codes and signals
- They can hang, crash, or produce unbounded output
- We need timeout capability at every layer
- Blocks for configuration
- Duck typing over rigid interfaces
- Composition over inheritance
- Explicit over implicit
# Basic
proctor = Umi::Proctor.new("redis-server")
# With arguments
proctor = Umi::Proctor.new("ffmpeg", "-i", input, "-o", output)
# With options
proctor = Umi::Proctor.new("server",
env: { "PORT" => "3000" },
chdir: "/app",
stderr: :merge, # merge stderr into stdout stream
line_buffered: true # receive line-by-line instead of chunks (default)
)
# Block form (auto-cleanup)
Umi::Proctor.open("redis-server") do |redis|
redis << "PING\r\n"
puts redis.receive
end # automatically killed and joined on block exitproctor << "raw bytes"
proctor.puts("line of text") # adds newline
proctor.close_stdin # signal EOF# Blocking receive (default: line-buffered)
line = proctor.receive
# With timeout (uses timer Ractor internally)
line = proctor.receive(timeout: 5.0)
# => line or raises Proctor::Timeout
# Check without blocking
if proctor.readable?
line = proctor.receive
end
# Enumerable interface
proctor.each_line do |line|
process(line)
end# Callback style - fires when process dies
proctor.on_exit do |result|
puts "Process exited: #{result.exit_code}"
puts "Signal: #{result.signal}" if result.signaled?
end
# Blocking wait
result = proctor.join
result = proctor.join(timeout: 30.0)
# Check without blocking
proctor.alive? # => true/false
proctor.exited? # => true/falseproctor.kill(:TERM)
proctor.kill(:KILL)
proctor.stop(timeout: 5.0) # TERM, wait, KILL if needed
proctor.close_stdinclass Proctor::Result
def exit_code # Integer 0-255, or nil if signaled
def signal # Symbol like :TERM, :KILL, or nil
def success? # exit_code == 0
def signaled? # killed by signal
def duration # Float seconds
def pid # Integer
endBased on spike_c's proven pattern:
- Watcher Ractor spawns process with
Open3.popen3internally - stdout_thread reads lines, sends
[:stdout, line]to parent - stderr_thread reads lines, sends
[:stderr, line]to parent - death_thread waits on
wait_thr.value, sends[:process_died, pid, code] - Command loop receives
[:stdin, data],[:close_stdin],[:kill, sig],[:shutdown] - Test:
catechoes input back
-
Proctor.new(cmd, *args)creates Watcher Ractor -
Proctor#<<sends[:stdin, data]to Watcher -
Proctor#receiveblocks on inbox port for[:stdout, line] -
Proctor#joinsends[:shutdown], waits for Watcher, returns Result -
Proctor#alive?checks Watcher status -
watcher.monitor(inbox)for Watcher crash detection - Test: full lifecycle with cat
- Collect
[:process_died, pid, exit_code]events -
Proctor#on_exitregisters callback - Callback fired when process_died received
- Also detect Watcher crash via
:abortedfrom monitor - Test: process exits normally, callback fires
- Test: process killed, callback fires with signal info
Timer Ractor pattern (no native Ractor.select timeout):
-
receive(timeout:)spawns timer Ractor - Timer sleeps, sends
:timeoutto timer_port -
Ractor.select(inbox, timer_port) - Return message or raise
Proctor::Timeout - Challenge: Clean up timer Ractor if message arrives first
- Test: slow process times out correctly
- stderr handling (separate events vs merged)
-
stop(timeout:)- TERM, wait, KILL escalation - Handle process that ignores SIGTERM
- Filter closed ports before
Ractor.select(spike finding) - Test: flaky process that sometimes hangs
- Block form with auto-cleanup
-
each_lineenumerable interface - Environment and chdir options
- Integration example with a real CLI tool
proctor = Proctor.new("cat")
proctor << "hello"
proctor.close_stdin
assert_equal "hello\n", proctor.receive # Note: cat adds newline
result = proctor.join
assert result.success?proctor = Proctor.new("sleep", "0.1")
exited = false
proctor.on_exit { exited = true }
sleep 0.2
assert exitedproctor = Proctor.new("sleep", "100")
assert_raises(Proctor::Timeout) do
proctor.receive(timeout: 0.1)
end
proctor.kill(:KILL)proctor = Proctor.new("ruby", "-e", "exit 42")
result = proctor.join
assert_equal 42, result.exit_code
refute result.success?proctor = Proctor.new("sleep", "100")
proctor.kill(:TERM)
result = proctor.join
assert result.signaled?
assert_equal :TERM, result.signalHow to detect death?→Ractor.monitor(port)sends:exited/:abortedCan we pass IO to Ractors?→ Spawn inside instead, works perfectlyDoes Ractor.select have timeout?→ No, use timer Ractor pattern
-
Timer Ractor cleanup - If message arrives before timeout, timer Ractor keeps running and will send
:timeoutlater. Options:- Let it complete, discard late
:timeoutmessages - Track timer Ractors, close their ports on message receipt
- Timer pool that can be cancelled
- Let it complete, discard late
-
Binary/raw mode - Spikes only tested
.getsline-by-line. For binary protocols, need.readpartialor.read_nonblock. Addmode: :rawoption? -
Buffer overflow - What if process writes faster than we read? OS buffers are ~64KB. Process blocks on write. Document this? Add internal buffering?
-
on_exit callback execution - Currently callbacks would fire in main thread when processing messages. Is this the right model? Should there be a dedicated callback thread?
-
Integration with devex - Can Proctor become foundation for devex
spawn? Need to consider environment stack (dotenv/mise/bundle).
Phase 1 is complete when:
- Basic bidirectional works - Can
catecho our input back - Death notification works -
on_exitcallback fires reliably - Timeout works - Can escape hung processes
- Tests pass - Automated verification of above
- Documentation - Clear examples of usage
- Clean code - Ready for review and iteration
Building the spikes taught us:
- Death detection -
Ractor.monitorexists and works! - How to multiplex -
Ractor.selectwith timer Ractor for timeout - Architecture - Spawn inside Ractor, threads for async I/O
- Ruby 4.0 API -
r.valuenotr.take,Ractor::Portis primary - Gotchas - Closed port in select fails immediately, need filtering
The foundational patterns are proven. Now we wrap them in a clean API.
- Spike findings:
spikes/FINDINGS.md,spikes/FINDINGS-DETAILS.md - Working pattern:
spikes/spike_c_watcher.rbFINAL test - Erlang Ports: https://www.erlang.org/doc/tutorial/c_port.html
- Ruby Process: https://ruby-doc.org/core/Process.html
- Ruby Ractor: https://ruby-doc.org/core/Ractor.html
- devex ADR-001: External Commands design