Open3.capture3 with timeout and RSS memory limit protection.
Runs a subprocess and captures its stdout/stderr, with optional hard limits on wall-clock time and resident memory. A single monitor thread handles both constraints so there is no signal-send race between them. Whole process groups are tracked by default, so grandchild processes are included in RSS accounting and are killed on OOM/timeout.
Add to your Gemfile:
gem "open3_safe"Or install directly:
gem install open3_safe
The interface mirrors Open3.capture3 — environment hash and spawn options are passed through to Process.spawn — with extra keys consumed before spawning.
result = Open3Safe.capture3_safe("echo", "hello")
result[:stdout] # => "hello\n"
result[:status].success? # => trueresult = Open3Safe.capture3_safe("sleep", "10", timeout: 3)
result[:timeout] # => true
result[:status].signaled? # => trueresult = Open3Safe.capture3_safe(
"ruby", "-e", "Array.new(200_000_000, 0); sleep 10",
max_rss: 100 * 1024 * 1024, # 100 MB
rss_check_interval: 0.5
)
result[:oom_killed] # => true
result[:peak_rss] # => integer, bytesresult = Open3Safe.capture3_safe(
"my_command", "--flag",
timeout: 30,
max_rss: 256 * 1024 * 1024,
kill_after: 5,
command_name: "my_command"
)# String
result = Open3Safe.capture3_safe("cat", stdin_data: "hello")
# IO object
IO.pipe do |r, w|
w.write("hello")
w.close
result = Open3Safe.capture3_safe("cat", stdin_data: r)
endAll options are passed as keyword arguments in the trailing hash, alongside any standard Process.spawn options.
| Option | Default | Description |
|---|---|---|
stdin_data |
"" |
String or IO to write to the command's stdin |
timeout |
nil |
Seconds after which the signal is sent |
signal |
:TERM |
Signal to send on timeout or OOM kill |
kill_after |
nil |
Seconds to wait after the initial signal before sending SIGKILL |
max_rss |
nil |
RSS byte threshold; kills the process if exceeded |
rss_check_interval |
2 |
Seconds between RSS polls (only used when max_rss is set) |
command_name |
inferred | Label used in callbacks; defaults to the command's basename |
pgroup |
true |
Spawn in a new process group; RSS is summed across all processes in the group and signals target the whole group |
Any other keys (e.g. :chdir, :env, unsetenv_others) are forwarded to Process.spawn.
capture3_safe returns a Hash:
| Key | Type | Description |
|---|---|---|
pid |
Integer | PID of the spawned process |
status |
Process::Status | Exit status |
stdout |
String | Captured standard output |
stderr |
String | Captured standard error |
timeout |
Boolean | true if killed due to timeout |
oom_killed |
Boolean | true if killed due to RSS exceeding max_rss |
peak_rss |
Integer or nil | Peak RSS in bytes observed during execution; nil when max_rss is not set |
duration |
Float | Wall-clock seconds the command ran for |
Configure callbacks to hook into kills and completions, e.g. for metrics or logging:
Open3Safe.configure do |c|
c.on_timeout_kill = ->(command_name, pid, timeout) {
MyMetrics.increment("open3_safe.timeout", tags: ["command:#{command_name}"])
}
c.on_rss_kill = ->(command_name, pid, rss, max_rss) {
MyLogger.warn("OOM kill", command: command_name, rss: rss, limit: max_rss)
}
c.on_finish = ->(command_name, result) {
MyMetrics.timing("open3_safe.duration", result[:duration], tags: ["command:#{command_name}"])
}
end| Callback | Arguments |
|---|---|
on_timeout_kill |
command_name, pid, timeout |
on_rss_kill |
command_name, pid, rss, max_rss |
on_finish |
command_name, result — the full result hash |
- Ruby >= 3.3.0
get_process_mem
MIT — see LICENSE.