Processes, Signals and Shell
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them.
A Ruby program rarely lives alone. It calls git, ffmpeg or sort, it is stopped with Ctrl-C or a kill from a supervisor, it hands work to a forked copy of itself and it is configured through environment variables. All of that goes through the operating system’s process model: a process is a running program with an identifier (the pid), an environment, three standard streams, a parent, an exit status that the parent can collect and a queue of signals, small asynchronous notifications that other processes (or the kernel) send to it. Ruby wraps this model in a handful of core pieces: the backtick operator and system for the simple cases, spawn and the Process module for control, fork and exec for the Unix primitives, Signal and trap for signals, ENV for the environment and, from the standard library, Open3 for talking to a child and Shellwords for quoting.
This page covers all of these, the failure modes that each one hides (zombies, deadlocked pipes, lost output after exit!, handlers that cannot take a lock) and the one security topic that runs through all of them: a command assembled from a string is parsed by a shell, so untrusted input can become commands. The Ruby 4.0 removal of the pipe form of Kernel#open is covered in its own section. How the ruby executable itself is started (-w, -I, -e, RUBYOPT) is in Ruby Command Line and Environment; writing a whole program around ARGV, OptionParser and exit codes is in Command-Line Programs; files, pipes as IO objects and StringIO are in IO, Files and Directories; threads, which interact with fork and with signals, are in Threads and the GVL; exceptions such as Interrupt and SystemExit are in Exceptions and Error Handling; and the wider security picture, including code injection and unsafe deserialisation, is in Security. Sockets are in Networking and HTTP, and the other standard-library pieces mentioned here (Timeout, Etc, Logger) have a short entry in Standard Library Tour. The open issue about a general comparison of command-line applications (#236) and the Go page about processes (#275) are not cross-referenced here.
Every example below was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 (macOS, arm64) and linted with RuboCop 1.92 using the configuration of the shelf gem (target Ruby 4.0, double-quoted strings, new cops enabled). Output shown is the real output. A few lines carry a deliberate rubocop:disable comment, because the example exists to show the very thing the cop warns about (Security/Open, Style/FetchEnvVar, Style/CommandLiteral, Lint/DeprecatedClassMethods, Style/SpecialGlobalVars). The child programs are POSIX tools (echo, sh, sort, sleep, cat), so the examples are for macOS and Linux; the Windows differences are collected in Windows and other platforms. Where an example prints a process id, the page replaces it with N because it changes on every run.
Ways to run another program
Ruby offers several interfaces, and the first decision is how much you need back from the child. The table orders them from simplest to most powerful.
| Interface | Returns | Child’s output | Use it when |
|---|---|---|---|
Backticks and |
The child’s standard output as a |
Captured (stdout only); stderr goes to the parent’s stderr |
You want a short text result and a non-zero status does not need special handling. |
|
|
Inherited: it appears on the parent’s terminal |
You want to run a command, show its output and check success. |
|
Does not return |
Inherited |
You want to replace the current process with another program (wrappers, launchers). |
|
A pid, immediately |
Inherited, or redirected with options |
You need the child to run in the background, several children at once or precise redirection. |
|
An |
Read or written through the |
You stream data to or from one child. |
|
Output, error and status; or three |
Stdout and stderr captured separately |
You need both streams, the exit status and, usually, the safest default. |
|
The child’s pid in the parent, |
Inherited |
You want a copy of the running Ruby process (Unix only). |
The rest of the page takes the interfaces in this order, after the two ideas that all of them share: the shell versus direct execution decision and the exit status.
Backticks and %x
The backtick operator runs a command and evaluates to what it wrote to standard output. %x(...) is the same operator with delimiters of your choice (useful when the command itself contains a backtick), and both support string interpolation, so a command such as echo #{word} written between backticks runs echo with the interpolated text. The result is a String in the default external encoding and includes the trailing newline.
# frozen_string_literal: true
out = `echo hello`
p out
p Process.last_status.class
p Process.last_status.success?
p Process.last_status.exitstatus
p %x(printf 'a\nb\n').lines # rubocop:disable Style/CommandLiteral
word = "ruby"
p `echo #{word} basics`
`sh -c 'echo to-stderr >&2; exit 3'`
p Process.last_status.exitstatus
begin
`no-such-program-xyz`
rescue Errno::ENOENT => e
puts "#{e.class}: #{e.message}"
end
p `exit 0`
p `sh -c 'echo only-stderr >&2'`
Output:
"hello\n"
Process::Status
true
0
["a\n", "b\n"]
"ruby basics\n"
to-stderr
3
Errno::ENOENT: No such file or directory - no-such-program-xyz
""
only-stderr
""
Source: Kernel backtick method
What the example shows:
-
Only standard output is captured. The line
to-stderrwent straight to the terminal, and the last backtick returned"". -
A failing command does not raise. The exit status is in
Process.last_status(the same object as the global$?; RuboCop prefers the former or$CHILD_STATUSfrom theEnglishlibrary, which is why the examples useProcess.last_status). The exit status3above is silent unless the program looks. -
A command that cannot be started at all (not found on
PATH, not executable) raisesErrno::ENOENT(orErrno::EACCES). This is the only failure that backticks turn into an exception. -
A string with shell meta-characters (a pipe, a redirection, a
;, a glob, a$) goes to/bin/sh -c. A plain word list is executed directly, without a shell. See A shell or a direct execution. -
Interpolating untrusted text into a backtick string is a command injection (Command injection). There is no array form of backticks; use
Open3.capture2orIO.popenwith an array when an argument comes from outside. -
The block
Process.last_statusis thread-local: it holds the status of the last child this thread waited for (Exit status and Process::Status).
system
Kernel#system runs a command, waits for it and reports a Boolean. The child shares the parent’s standard streams, so its output appears wherever the parent’s would.
# frozen_string_literal: true
p system("echo", "from system")
p system("exit 2")
p Process.last_status.exitstatus
p system("no-such-program-xyz")
p Process.last_status.exitstatus
begin
system("sh", "-c", "exit 4", exception: true)
rescue RuntimeError => e
puts "#{e.class}: #{e.message}"
end
begin
system("no-such-program-xyz", exception: true)
rescue Errno::ENOENT => e
puts "#{e.class}: #{e.message}"
end
system({ "GREETING" => "hello" }, "sh", "-c", 'echo "$GREETING from the child"')
system("pwd", chdir: "/usr")
system("sh", "-c", "echo out; echo err >&2", err: File::NULL)
system("sh", "-c", "echo $HOME-is-gone", unsetenv_others: true)
system("sh", "-c", "echo to-err", out: :err)
Output (the /usr line is the child’s working directory):
from system
true
false
2
nil
127
RuntimeError: Command failed with exit 4: sh
Errno::ENOENT: No such file or directory - no-such-program-xyz
hello from the child
/usr
out
-is-gone
to-err
Source: Kernel#system
The return value has three states, and the third is the one people forget:
| Value | Meaning |
|---|---|
|
The command ran and exited with status 0. |
|
The command ran and exited with a non-zero status ( |
|
The command could not be run at all (not found, not executable). |
So system("make") or abort hides a missing make behind the same branch as a failing one, but that is usually what you want; if you need to tell them apart, test for nil. The keyword exception: true turns both failures into exceptions: a non-zero status raises RuntimeError (message Command failed with exit 4: sh) and a command that cannot be started raises the Errno error (Errno::ENOENT, Errno::EACCES). It is the simplest way to make a script stop on the first failing step and is the recommended default in build scripts.
The full call form is system([env,] command... [, options]):
-
An optional first
Hashsets or removes environment variables in the child only (anilvalue removes the variable). The parent’sENVis not touched. -
The command is either one string (a command line, see A shell or a direct execution) or a program followed by its arguments as separate strings (no shell, no quoting problems). Always prefer the second form when any part is dynamic.
-
A trailing
Hashcarries execution options shared withspawnandexec:chdir:,unsetenv_others:,out:,err:,in:,umask:,pgroup:and others, documented in Execution options. The example useserr: File::NULLto discard the child’s standard error,unsetenv_others: trueto start the child with an empty environment (so$HOMEis empty) andout: :errto send the child’s standard output to the parent’s standard error.
system blocks the calling thread but not the other Ruby threads: Ruby releases the GVL while it waits for the child (Time limits returns to this).
A shell or a direct execution
Every interface above takes either a command line (one string) or a program and its arguments (several strings). The difference decides how the text is interpreted and, with it, how safe the call is.
-
With one string, Ruby inspects it. If it contains shell meta-characters (quotes,
$,;,|,&,<,>,*,?, backslash and similar) or starts with a shell reserved word or a shell built-in such asexitorif, the whole string is passed to the shell as/bin/sh -c "<string>". If it contains only words separated by spaces and tabs, Ruby splits it itself and executes the program directly, with no shell. Sosystem("echo hi")has no shell involved, whilesystem("echo hi; echo there")has one. -
With several strings, Ruby never uses a shell: the first string is the program, looked up in
PATHwhen it contains no slash, and the rest are its arguments, passed unchanged as separateargventries. There is no quoting to get right and no expansion of$,*or~. -
A two-element array as the first argument,
[program, argv0], sets the name the child sees as its ownargv[0](the example in Execution options shows it). It is also how you run a path that contains spaces without a shell.
On Windows the rules differ slightly (Windows and other platforms).
A shell-less call fails differently from a shell call when the program is missing. A direct call reports the problem to Ruby (system returns nil, spawn raises Errno::ENOENT), while a shell call starts the shell successfully and the shell reports exit status 127 and prints a message on standard error. The example below shows the combinations.
# frozen_string_literal: true
p system("no-such-program-xyz with args")
p Process.last_status.exitstatus
p system("no-such-program-xyz; true", err: File::NULL)
p system("no-such-program-xyz", "x", exception: false)
p system("no-such-program-xyz | cat", err: File::NULL)
system("sh", "-c", "no-such-program-xyz", err: File::NULL)
p Process.last_status.exitstatus
File.write("not-executable.sh", "echo hi\n")
p system("./not-executable.sh")
p Process.last_status.exitstatus
begin
system("./not-executable.sh", exception: true)
rescue Errno::EACCES => e
puts "#{e.class}: #{e.message}"
ensure
File.delete("not-executable.sh")
end
system("sh", "-c", "kill -INT $$")
p [Process.last_status.exitstatus, Process.last_status.signaled?, Process.last_status.termsig]
system("sh", "-c", "sh -c 'kill -INT $$'; echo \"inner shell reported $?\"")
p Process.last_status.equal?(Process.last_status)
system("sh", "-c", "exit 9")
Thread.new do
system("sh", "-c", "exit 6")
puts "thread: #{Process.last_status.exitstatus}"
end.join
puts "main: #{Process.last_status.exitstatus}"
Output:
nil
127
true
nil
true
127
nil
127
Errno::EACCES: Permission denied - ./not-executable.sh
[nil, true, 2]
inner shell reported 130
true
thread: 6
main: 9
Source: Process: Execution Shell
Reading the output in order:
-
system("no-such-program-xyz with args")has no meta-characters, so it is executed directly and returnsnil; the status is 127. -
system("no-such-program-xyz; true", ...)contains;, so a shell runs it, the shell ignores the failure of the first command and the second succeeds: the result istrue. A shell string can hide a failure that the array form reports. -
system("no-such-program-xyz", "x", exception: false)isnil. In"no-such-program-xyz | cat"the status of a pipeline is the status of its last command (cat), so the line istrue. Addset -o pipefailinside an explicitsh -c(or useOpen3.pipeline, Open3) when every stage matters. -
The explicit
sh -c "no-such-program-xyz"returns the shell’s127. -
A file without the execute bit gives
nilwith status 127 and, withexception: true,Errno::EACCES. -
A child killed by a signal has no exit code:
exitstatusisnil,signaled?istrueandtermsigis2(SIGINT). The shell that ran it would report 128 + 2 = 130, which is what the inner shell printed. -
Process.last_statusis the same object as$?and is stored per thread: the thread that ransh -c "exit 6"saw 6, the main thread still saw 9.
Exit status and Process::Status
Every child ends with a number (0 to 255) or with a signal. Ruby represents the outcome as a Process::Status, available as Process.last_status (or $?) after system, backticks, Process.wait and friends, and returned by Process.wait2, Open3.capture3 and the thread of Open3.popen3.
# frozen_string_literal: true
system("sh", "-c", "exit 7")
st = Process.last_status
p st.exitstatus
p st.success?
p st.exited?
p st.signaled?
p st.termsig
p st.to_i
p st.pid.is_a?(Integer)
puts st.inspect.sub(/pid \d+/, "pid N")
puts st.to_s.sub(/pid \d+/, "pid N")
system("sh", "-c", "kill -TERM $$")
st = Process.last_status
p st.exitstatus
p st.signaled?
p st.termsig
p Signal.signame(st.termsig)
p st.success?
puts st.inspect.sub(/pid \d+/, "pid N")
system("sh", "-c", "exit 0")
p Process.last_status.success?
Output:
7
false
true
false
nil
1792
true
#<Process::Status: pid N exit 7>
pid N exit 7
nil
true
15
"TERM"
nil
#<Process::Status: pid N SIGTERM (signal 15)>
true
Source: Process::Status
The methods, by what they answer:
| Method | Meaning |
|---|---|
|
The exit code, or |
|
|
|
|
|
|
|
Only for children waited for with |
|
|
|
The child’s process id. |
|
The raw wait status word: the exit code shifted left by 8 bits (7 becomes 1792). Prefer |
Process::Status#& and Process::Status#>>, which exposed that raw word to bit arithmetic, were deprecated in Ruby 3.3 and removed in Ruby 4.0; code that wrote $? >> 8 or $? & 0xff must use exitstatus and termsig (the check Process::Status.public_method_defined?(:&) is true on Ruby 3.2, 3.3 and 3.4 and false on 4.0).
Conventions for the numbers, which Ruby does not enforce but every shell and supervisor follows:
| Code | Usual meaning |
|---|---|
0 |
Success. |
1 |
General failure. |
2 |
Misuse of the command (bad options). |
64 to 78 |
The |
126 |
The command was found but cannot be executed. |
127 |
The command was not found (what a shell reports, and what Ruby’s |
128 + n |
The shell’s report that a child died from signal |
Inside your own program, exit, exit! and abort set the code (abort("message") writes the message to standard error and exits with 1), and an uncaught exception also exits with 1; the details are in Command-Line Programs.
spawn, wait and zombies
Kernel#spawn (also Process.spawn) starts a child and returns its pid at once, without waiting. It takes the same arguments as system, so the shell-or-direct rules and the execution options are identical. A started child must be waited for, or it becomes a zombie.
# frozen_string_literal: true
require "tmpdir"
pid = spawn("sh", "-c", "echo child; exit 3")
p pid.is_a?(Integer)
waited, status = Process.wait2(pid)
p waited == pid
p status.exitstatus
pid = spawn("sleep", "5")
p Process.wait(pid, Process::WNOHANG)
Process.kill("TERM", pid)
Process.wait(pid)
p Process.last_status.signaled?
p Process.last_status.termsig
begin
Process.wait
rescue Errno::ECHILD => e
puts "#{e.class}: #{e.message}"
end
reader, writer = IO.pipe
pid = spawn("echo", "through a pipe", out: writer)
writer.close
Process.wait(pid)
p reader.read
pid = spawn("sh", "-c", "echo $0 $1", "a b", "c")
Process.wait(pid)
pids = Array.new(3) { |i| spawn("sh", "-c", "exit #{i}") }
codes = pids.map { |child| Process.wait2(child).last.exitstatus }
p codes
watcher = Process.detach(spawn("sh", "-c", "exit 9"))
p watcher.class
p watcher.value.exitstatus
log = File.join(Dir.tmpdir, "shelf-spawn-#{Process.pid}.log")
pid = spawn("echo", "logged", out: [log, "w"])
Process.wait(pid)
p File.read(log)
File.delete(log)
Output:
true
child
true
3
nil
true
15
Errno::ECHILD: No child processes
"through a pipe\n"
a b c
[0, 1, 2]
Process::Waiter
9
"logged\n"
Source: Process.spawn
The wait family, in one table:
| Call | Behaviour |
|---|---|
|
Blocks until the given child (or any child with |
|
Same, but returns |
|
Waits for all children and returns an array of |
|
Returns |
|
Also returns for children that are stopped, not only ended. |
|
Starts a background thread that waits for the child and returns a |
A child that ends but has not been waited for stays in the process table as a zombie: it uses no CPU or memory, but its pid slot is held until the parent collects the status. A long-running parent that spawns children and never waits eventually runs out of pids. The next example shows the state with ps.
# frozen_string_literal: true
def state(pid) = `ps -o stat= -p #{pid}`.strip
pid = spawn("true")
sleep 0.2
puts "not yet waited: #{state(pid)[0]}"
Process.wait(pid)
puts "after wait: #{state(pid).empty? ? 'gone' : 'still there'}"
pid = spawn("true")
Process.detach(pid)
sleep 0.2
puts "with detach: #{state(pid).empty? ? 'gone' : 'still there'}"
Output:
not yet waited: Z
after wait: gone
with detach: gone
Source: Process.detach
Z is the zombie state. Either Process.wait the child, or Process.detach it right after spawn when you do not care about its result. If the parent exits first, the init process adopts the children and reaps them.
Execution options
The trailing hash of spawn, system, exec and the IO.popen/Open3 family accepts the following keys (names from the Process documentation):
| Option | Effect |
|---|---|
|
Working directory of the child; the parent’s directory is unchanged. |
|
Start the child with an empty environment, then apply the leading env hash. |
|
File-creation mask of the child. |
|
Put the child in a new process group (or an existing one); see Process groups. |
|
Redirect a standard stream (the source) to a destination: a path (opened for reading or writing), |
|
An array key redirects several descriptors to one destination. |
|
Close all non-standard descriptors (3 and above) that are not redirected; the default is to inherit them, but Ruby sets close-on-exec on its own descriptors. |
|
Resource limits ( |
# frozen_string_literal: true
require "tmpdir"
Dir.mktmpdir do |dir|
path = File.join(dir, "titles.txt")
File.write(path, "b\na\n")
pid = spawn("sort", in: path, out: File.join(dir, "sorted.txt"))
Process.wait(pid)
p File.read(File.join(dir, "sorted.txt"))
pid = spawn("sh", "-c", "echo out; echo err >&2", %i[out err] => File.join(dir, "both.txt"))
Process.wait(pid)
p File.read(File.join(dir, "both.txt")).lines.sort
pid = spawn("sh", "-c", "echo hi >&2", err: %i[child out], out: File.join(dir, "merged.txt"))
Process.wait(pid)
p File.read(File.join(dir, "merged.txt"))
pid = spawn("pwd", chdir: dir, out: File.join(dir, "cwd.txt"))
Process.wait(pid)
p File.read(File.join(dir, "cwd.txt")).strip == File.realpath(dir)
pid = spawn("sh", "-c", "umask", umask: 0o077, out: File.join(dir, "umask.txt"))
Process.wait(pid)
p File.read(File.join(dir, "umask.txt")).strip
pid = spawn("sh", "-c", "echo closed >&2", err: :close, out: File::NULL)
Process.wait(pid)
p Process.last_status.success?
end
pid = spawn(%w[sh custom-argv0], "-c", 'echo "$0"', out: :err)
Process.wait(pid)
pid = spawn("sleep", "0", rlimit_cpu: 1)
Process.wait(pid)
p Process.last_status.success?
begin
spawn("echo", bogus_option: 1)
rescue ArgumentError => e
puts "#{e.class}: #{e.message}"
end
Output:
"a\nb\n"
["err\n", "out\n"]
"hi\n"
true
"0077"
false
custom-argv0
true
ArgumentError: wrong exec option symbol: bogus_option
Source: Process: Execution Options
Note the failure mode of err: :close: the child still ran, but writing to a closed descriptor failed, so the child reported a non-zero status. An unknown option is an ArgumentError raised before anything is started.
IO.popen and the removal of Kernel#open with a pipe
IO.popen starts a child and returns an IO object connected to its standard input, its standard output or both (mode "r", "w" or "r+"). Given a block, it yields the IO, closes it afterwards, waits for the child and sets Process.last_status.
# frozen_string_literal: true
IO.popen(["sort", "-r"], "r+") do |io|
io.puts "apple"
io.puts "pear"
io.close_write
p io.read
end
p Process.last_status.success?
lines = IO.popen(%w[printf a\nb\n], &:readlines)
p lines
io = IO.popen({ "WHO" => "shelf" }, ["sh", "-c", 'echo "hi $WHO"'], err: %i[child out])
p io.read
io.close
p Process.last_status.exitstatus
IO.popen(["sh", "-c", "echo e >&2; echo o"], err: %i[child out]) { |pipe| p pipe.readlines }
p IO.popen("-") { |child| child ? child.read : print("from the forked child") }
Output:
"pear\napple\n"
true
["a\n", "b\n"]
"hi shelf\n"
0
["e\n", "o\n"]
"from the forked child"
Source: IO.popen
Points:
-
The array form (
IO.popen(["sort", "-r"], ...)) runs without a shell; the string form follows the shell-or-direct rule above. As withsystem, a leadingHashsets the child’s environment and a trailingHashcarries execution options.err: %i[child out]merges the child’s stderr into the stream thatreadreturns. -
In
"r+"mode the parent writes, callsclose_write(otherwise the child waits forever for more input) and then reads. -
The special command
"-"forks the Ruby process itself: the child runs the same block withnil. It is Unix-only, likefork. -
Reading from a child while the child is blocked writing to a different, full pipe is a deadlock, and
popengives you only one pipe in each direction; useOpen3when both output streams matter (Open3).
The removal of Kernel#open with a leading pipe in Ruby 4.0
For decades open("| ls") started a process: Kernel#open, and the IO class methods IO.read, IO.readlines, IO.write, IO.foreach and friends, treated a path beginning with a pipe character as a command. That made every open(user_supplied_name) a command-injection hazard (a name such as | rm -rf ... ran). It was deprecated in Ruby 3.3 and removed in Ruby 4.0: a leading | is now an ordinary file name.
# frozen_string_literal: true
begin
open("|echo hi") # rubocop:disable Security/Open
rescue Errno::ENOENT => e
puts "Kernel#open: #{e.class}: #{e.message}"
end
begin
File.read("|echo hi")
rescue Errno::ENOENT => e
puts "File.read: #{e.class}: #{e.message}"
end
begin
IO.read("|echo hi")
rescue Errno::ENOENT => e
puts "IO.read: #{e.class}: #{e.message}"
end
puts IO.popen(%w[echo hi], &:read)
puts IO.popen(%w[tr a-z A-Z], "r+") { |io|
io.write "shelf"
io.close_write
io.read
}
Output:
Kernel#open: Errno::ENOENT: No such file or directory @ rb_sysopen - |echo hi
File.read: Errno::ENOENT: No such file or directory @ rb_sysopen - |echo hi
IO.read: Errno::ENOENT: No such file or directory @ rb_sysopen - |echo hi
hi
SHELF
Source: NEWS for Ruby 4.0.0 (Feature #19630)
Behaviour by version, verified by running the same file on each Ruby:
| Ruby | open("|echo hi") and IO.read("|echo hi") |
|---|---|
3.2 |
|
3.3 and 3.4 |
Still ran the command, with the warning |
4.0 |
Raises |
The migration is mechanical: replace open("|cmd args") by IO.popen(["cmd", "args"]) (an array), or by Open3.capture2. The RuboCop cop Security/Open flags every Kernel#open with a non-literal argument for this reason; the example silences it on purpose. Opening a URL used to be a second use of Kernel#open through open-uri; that was removed long before and is URI.open today (see Networking and HTTP).
Open3
Open3 (a default library; require "open3") is the usual answer when you need the child’s output and error and status. It starts the child without a shell when you pass an argument array, and it reads both streams concurrently so a chatty child cannot deadlock the parent.
# frozen_string_literal: true
require "open3"
out, err, status = Open3.capture3("sh", "-c", "echo result; echo problem >&2; exit 2")
p out
p err
p status.exitstatus
out, status = Open3.capture2("sort", stdin_data: "pear\napple\nfig\n")
p out
p status.success?
merged, status = Open3.capture2e("sh", "-c", "echo one; echo two >&2")
p merged
p status.success?
out, = Open3.capture3({ "SHELF_MODE" => "demo" }, "sh", "-c", 'echo "mode=$SHELF_MODE"')
p out
out, = Open3.capture3("pwd", chdir: "/usr")
p out
Open3.popen3("cat") do |stdin, stdout, stderr, wait_thr|
stdin.puts "line one"
stdin.puts "line two"
stdin.close
p stdout.read
p stderr.read
p wait_thr.value.success?
p wait_thr.pid == wait_thr.value.pid
end
Open3.popen2("tr", "a-z", "A-Z") do |stdin, stdout, thread|
stdin.write "shelf"
stdin.close
p stdout.read
p thread.value.exitstatus
end
begin
Open3.capture3("no-such-program-xyz")
rescue Errno::ENOENT => e
puts "#{e.class}: #{e.message}"
end
statuses = Open3.pipeline("echo b; echo a", "sort", "head -n 1")
p statuses.map(&:exitstatus)
results = Open3.pipeline_r("printf 'b\na\nc\n'", "sort") { |last_out, _threads| last_out.read }
p results
Output:
"result\n"
"problem\n"
2
"apple\nfig\npear\n"
true
"one\ntwo\n"
true
"mode=demo\n"
"/usr\n"
"line one\nline two\n"
""
true
true
"SHELF"
0
Errno::ENOENT: No such file or directory - no-such-program-xyz
a
[0, 0, 0]
"a\nb\nc\n"
Source: Open3
The family:
| Method | Returns |
|---|---|
|
|
|
|
|
|
|
Without a block: the |
|
Connect several commands with pipes, like |
All of them accept the same optional leading environment hash and trailing execution options as spawn (chdir:, umask:, …). A command that cannot be started raises Errno::ENOENT, as in the example. For the strings returned, mind the encoding: they carry the default external encoding, so pass binmode: true for binary output.
The classic deadlock is worth naming. If you use popen3 and read stdout to the end before touching stderr, a child that writes more than a pipe buffer (typically 64 KiB) to stderr blocks forever on its write while you block on stdout. capture3 avoids it by reading both streams with helper threads; with popen3, read the two in separate threads or call capture3 instead. Close the child’s stdin (as the block form above does with stdin.close) when you have no more input, or filters such as sort and cat wait for ever.
The next example applies this to the running shelf gem: the catalogue’s titles are sorted by the system sort, a failure becomes a domain error and the environment is pinned for a stable ordering.
# frozen_string_literal: true
require "open3"
require "shelf"
# Sort the catalogue titles with the system sort(1) and report failures.
module ShelfSorter
class Failed < StandardError; end
def self.call(catalog)
titles = catalog.map(&:title).join("\n")
out, err, status = Open3.capture3({ "LC_ALL" => "C" }, "sort", stdin_data: titles)
raise Failed, "sort exited #{status.exitstatus}: #{err.strip}" unless status.success?
out.lines(chomp: true)
end
end
author = Shelf::Author.new("A. Writer")
catalog = Shelf::Catalog.new(store: Shelf::Store::Memory.new)
["ruby basics", "algorithms", "design patterns"].each do |title|
catalog.add(Shelf::Book.new(Shelf::Isbn.new("978-0-306-40615-7"), title, author))
end
p ShelfSorter.call(catalog)
_out, err, status = Open3.capture3("sort", "--no-such-flag")
puts "exit #{status.exitstatus}: #{err.lines.first&.strip}"
Run from the gem root as ruby -w -Ilib shelf_sort.rb:
["algorithms", "design patterns", "ruby basics"]
exit 2: sort: unrecognized option `--no-such-flag'
Source: Open3.capture3
The wording of the second line is the BSD sort of macOS; a GNU sort prints unrecognized option '--no-such-flag'.
Command injection
A shell string is a program written in the shell’s language. When part of it comes from outside (a file name, a search term, a query parameter), the outsider can write shell code. The Ruby security documentation lists the dangerous calls as Kernel#system, Kernel#spawn, Kernel#exec, backticks, %x and IO.popen/Open3 with a string command; the fix is the same for all: pass the arguments as separate strings.
# frozen_string_literal: true
require "shellwords"
hostile = "ruby; echo INJECTED"
puts "-- string form: the shell parses the whole line"
system("echo searching for #{hostile}")
puts "-- array form: no shell, the value is one argument"
system("echo", "searching for", hostile)
puts "-- string form with Shellwords.escape"
system("echo searching for #{Shellwords.escape(hostile)}")
puts "-- backtick with interpolation has the same problem"
puts `echo #{hostile}`
puts "-- a value that starts with a dash is read as an option"
name = "-n"
p system("ls", name, out: File::NULL)
p system("ls", "--", name, out: File::NULL, err: File::NULL)
Output:
-- string form: the shell parses the whole line
searching for ruby
INJECTED
-- array form: no shell, the value is one argument
searching for ruby; echo INJECTED
-- string form with Shellwords.escape
searching for ruby; echo INJECTED
-- backtick with interpolation has the same problem
ruby
INJECTED
-- a value that starts with a dash is read as an option
true
false
Source: Command injection
Reading the output:
-
In the first block the semicolon ended the
echoand a second command,echo INJECTED, ran. A real attacker would not print a word. -
The array form passed the whole hostile text to
echoas one argument. Nothing was executed. -
Shellwords.escapemakes a string form safe by backslash-escaping everything the shell would interpret. It works, but it is a second-best: you must remember it at every interpolation, and the quoting rules are those of the POSIX shell, not ofcmd.exe. -
Backticks interpolate into a shell string and have no array form.
-
The last two lines are a different bug, argument injection: even without a shell, a value that begins with a dash is read as an option by the child (
ls -n). Put--before values the user controls when the program supports it, and validate or prefix file names (./-n). The first call returnedtrue, the secondfalsebecauselsfound no file called-n.
A checklist that follows from the documentation:
-
Prefer
Open3orsystemwith an argument array. Never build the command with"#{...}"from untrusted data. -
Do not use a shell for what Ruby can do itself: use
File,FileUtils,DirandPathnameinstead ofrm,cp,lsandfind; and the standard libraries instead ofcurl. -
If a shell is unavoidable, escape every interpolated piece with
Shellwords.escape(or build the whole line withShellwords.join), and treat the result as a last resort. -
Keep
PATHtrusted. A relative or user-writablePATHlets an attacker supply a program with the name you call; give an absolute path for sensitive programs. -
Run children with the least privilege and the smallest environment (
unsetenv_others: true), since the environment can carry secrets and options (LD_PRELOAD,GIT_SSH_COMMAND). -
Never run
evalorKernel#sendon input; that is code injection, covered in Security.
Shellwords
Shellwords (require "shellwords", a default library) implements the POSIX shell’s word-splitting and quoting rules in Ruby. It does not run anything; it converts between a line and an array of words.
# frozen_string_literal: true
require "shellwords"
p Shellwords.split(%(search --title "ruby basics" 'single quoted' plain\\ space))
p "a b 'c d'".shellsplit
p Shellwords.shellwords("one two")
p Shellwords.escape("it's a file.txt")
p "plain".shellescape
p "".shellescape
p Shellwords.escape("two\nlines")
p Shellwords.join(["ls", "-l", "my books"])
p %w[grep -e a;b].shelljoin
p ["ls", "my books"].shelljoin
begin
Shellwords.split("unterminated 'quote")
rescue ArgumentError => e
puts "#{e.class}: #{e.message}"
end
cmd = Shellwords.split("echo 'hello shell'")
system(*cmd)
Output:
["search", "--title", "ruby basics", "single quoted", "plain space"]
["a", "b", "c d"]
["one", "two"]
"it\\'s\\ a\\ file.txt"
"plain"
"''"
"two'\n'lines"
"ls -l my\\ books"
"grep -e a\\;b"
"ls my\\ books"
ArgumentError: Unmatched quote at 13: ...'
hello shell
Source: Shellwords
-
Shellwords.split,shellwordsandshellsplit(and theString#shellsplitextension) turn a command line into the argument array thatsystemwants: the array form can then be used with text that someone typed in a shell-like syntax. Unterminated quotes raiseArgumentError. -
Shellwords.escape,shellescape(andString#shellescape) quote one word; an empty string becomes''and a newline is wrapped in single quotes. -
Shellwords.join,shelljoin(andArray#shelljoin) escape and join an array into one command line, the inverse ofsplit. -
It speaks the Bourne-shell dialect only. For
cmd.exeor PowerShell there is no equivalent; use the array form.
exec
Kernel#exec replaces the running process with another program. The pid stays the same, nothing after the call runs, no ensure or at_exit block runs, and it only returns (by raising) when the program cannot be started. It takes the same arguments as system and spawn. Use it when your Ruby script is a launcher that prepares the environment and then becomes the real program, so signals and the exit status go straight to that program, and in a forked child before running a different command (the pattern fork { exec(...) } is what spawn does for you).
# frozen_string_literal: true
begin
exec("no-such-program-xyz")
rescue Errno::ENOENT => e
puts "#{e.class}: #{e.message}"
end
Output:
Errno::ENOENT: No such file or directory - no-such-program-xyz
Source: Kernel#exec
Open file descriptors survive exec unless they have close-on-exec set; Ruby sets it on the descriptors it opens, so a plain exec does not leak your files into the new program.
fork
Kernel#fork (also Process.fork) is the Unix system call: it creates a copy of the running process. Both continue after the call. In the parent fork returns the child’s pid; in the child it returns nil. With a block the child runs the block and exits, with status 0 unless the block raises.
# frozen_string_literal: true
counter = 0
pid = fork do
counter += 1
puts "child sees counter=#{counter}"
exit 5
end
Process.wait(pid)
puts "parent sees counter=#{counter}, child status=#{Process.last_status.exitstatus}"
reader, writer = IO.pipe
child = fork do
reader.close
writer.puts "result computed in #{Process.pid == Process.ppid ? 'impossible' : 'the child'}"
writer.close
end
writer.close
puts reader.read
Process.wait(child)
puts "fork supported: #{Process.respond_to?(:fork)}"
if (pid = fork)
Process.wait(pid)
puts "parent: child exited with #{Process.last_status.exitstatus}"
else
puts "child: after fork without a block"
$stdout.flush
exit! 0
end
at_exit { puts "at_exit in #{Process.pid == PARENT ? 'parent' : 'child'}" }
PARENT = Process.pid
fork { puts "child exits normally" }
Process.wait
fork do
puts "child leaves with exit!"
$stdout.flush
exit!
end
Process.wait
Output:
child sees counter=1
parent sees counter=0, child status=5
result computed in the child
fork supported: true
child: after fork without a block
parent: child exited with 0
child exits normally
at_exit in child
child leaves with exit!
at_exit in parent
Source: Process.fork
Facts the example shows:
-
The child gets its own copy of memory: the change to
counterin the child is invisible to the parent. To send data back, use a pipe (IO.pipe, as above), a file, a socket orMarshalover a pipe. -
exit 5in the child gives the parent status 5. -
at_exithandlers are inherited and run in the child at a normal exit (at_exit in childis printed), and then again in the parent. A child that must not run the parent’s handlers leaves withexit!, which skips them (andensureblocks). -
On modern systems the copy is lazy (copy-on-write): pages are shared until one process writes. A forked worker slowly un-shares memory as the garbage collector and the program write to pages;
Process.warmup(Ruby 3.3), which notifies the VM that the boot sequence is finished so that it may begin optimizing, is meant to be called by a pre-fork server before it forks workers.Process._fork(present in Ruby 3.2 and later; checked on 3.2, 3.3, 3.4 and 4.0) is the hook thatforkcalls; libraries can override it to run code in the child.
# frozen_string_literal: true
pid = fork { raise ArgumentError, "boom in the child" }
Process.wait(pid)
puts "child status: #{Process.last_status.exitstatus}"
pid = fork { Process.kill("KILL", Process.pid) }
Process.wait(pid)
p Process.last_status.signaled?
p Process.last_status.termsig
pid = fork { exec("echo", "replaced by echo") }
Process.wait(pid)
p Process.last_status.success?
begin
exec("no-such-program-xyz")
rescue Errno::ENOENT => e
puts "#{e.class}: #{e.message}"
end
Output (the first lines are the child’s error report on standard error):
fork_exceptions.rb:3:in 'block in <main>': boom in the child (ArgumentError)
pid = fork { raise ArgumentError, "boom in the child" }
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
from fork_exceptions.rb:3:in 'Kernel#fork'
from fork_exceptions.rb:3:in '<main>'
child status: 1
true
9
replaced by echo
true
Errno::ENOENT: No such file or directory - no-such-program-xyz
Source: Kernel#fork
An uncaught exception in the child ends the child with status 1 and the parent sees only the status. A child killed by a signal has termsig 9. fork { exec(...) } replaces the child by another program.
What a forked child inherits and loses
# frozen_string_literal: true
worker = Thread.new { sleep }
sleep 0.05
puts "parent threads: #{Thread.list.size}"
pid = fork do
puts "child threads: #{Thread.list.size}"
puts "worker alive in child: #{worker.alive?}"
end
Process.wait(pid)
worker.kill
Output:
parent threads: 2
child threads: 1
worker alive in child: false
Source: Process.fork
Only the thread that called fork survives in the child. A background thread of the parent is gone, and so is its state: a mutex it held is held forever (a lock held at the moment of the fork stays locked in the child), and a connection pool that relied on its reaper thread no longer has one. The child also shares open file offsets and sockets with the parent. Rules that follow:
-
Fork before starting threads, or recreate them in the child.
-
Close or reopen network connections and database handles in the child; two processes writing on one socket interleave bytes.
-
Flush buffered output before forking, or the buffer is written twice (once by each process).
-
Leave a child with
exit!after flushing when it must not run the parent’sat_exithandlers.
# frozen_string_literal: true
pid = fork do
$stdout.print "buffered, never flushed"
exit!
end
Process.wait(pid)
puts "parent: the child's partial line did not appear"
pid = fork do
$stdout.print "flushed first"
$stdout.flush
exit!
end
Process.wait(pid)
puts
Output:
parent: the child's partial line did not appear
flushed first
Source: Process.exit!
exit! ends the process at once, without ensure, at_exit or flushing Ruby’s buffers. The first child printed text without a newline and lost it; the second flushed explicitly and kept it.
The lifecycle of a classic fork-and-wait:
Platform limits of fork
-
Windows has no
fork.Process.respond_to?(:fork)isfalsethere and callingforkraisesNotImplementedError. Usespawn,Open3or threads, or a Ractor (Ractors) for in-process parallelism. -
Other Ruby implementations may not provide
forkeither, so portable libraries testProcess.respond_to?(:fork)instead of the name of the operating system. -
Forking a process that has other threads is allowed, but the child has only the calling thread (see the list above). The Ruby 4.0 NEWS lists a fix for issues with processes forking after starting a Ractor; the Ractor API is experimental (Ractors).
-
spawndoes not carry these problems, because it starts a fresh program withexecinstead of continuing a copy of a multi-threaded process.
Process.daemon(nochdir = nil, noclose = nil) detaches the process from its controlling terminal and runs it in the background; by default it changes the working directory to the root directory and redirects the standard streams to the null device (the two arguments switch those off). Today a service manager such as systemd or a container runtime makes this unnecessary, and such managers want the program to stay in the foreground.
Signals
A signal is a small integer message the kernel or another process delivers to a process asynchronously. Each has a name (INT, TERM, HUP, USR1, CHLD) and a default action: ending the process, ignoring the signal, stopping or continuing it, or dumping core. A program can catch most signals with a handler. Two cannot be caught or ignored: KILL (9) and STOP.
The list depends on the system and on how it numbers them; names are portable, numbers are not (USR1 is 10 on Linux and 30 on macOS).
# frozen_string_literal: true
list = Signal.list
p list.size > 25
p list.slice("EXIT", "HUP", "INT", "QUIT", "KILL", "TERM")
p Signal.signame(15)
p Signal.signame(2)
p Signal.signame(999)
[%w[KILL], %w[STOP], %w[SEGV], %w[VTALRM]].each do |(name)|
trap(name) { nil }
rescue ArgumentError, SystemCallError => e
puts "#{name}: #{e.class}: #{e.message}"
end
begin
trap("NOPE") { nil }
rescue ArgumentError => e
puts "#{e.class}: #{e.message}"
end
p trap("USR1") { puts "first" }
p trap("USR1") { puts "second" }.class
Process.kill("USR1", Process.pid)
sleep 0.1
p trap("USR1", "DEFAULT").class
p trap("USR1", "IGNORE")
p trap("USR1", "SYSTEM_DEFAULT")
p trap("USR1", nil)
Output:
true
{"EXIT" => 0, "HUP" => 1, "INT" => 2, "QUIT" => 3, "KILL" => 9, "TERM" => 15}
"TERM"
"INT"
nil
KILL: Errno::EINVAL: Invalid argument - SIGKILL
STOP: Errno::EINVAL: Invalid argument - SIGSTOP
SEGV: ArgumentError: can't trap reserved signal: SIGSEGV
VTALRM: ArgumentError: can't trap reserved signal: SIGVTALRM
ArgumentError: unsupported signal 'SIGNOPE'
"DEFAULT"
Proc
second
Proc
"DEFAULT"
"IGNORE"
"SYSTEM_DEFAULT"
Source: Signal
-
Signal.listmaps names to numbers ("EXIT"is 0, a pseudo-signal Ruby uses for the process end).Signal.signame(number)maps back and returnsnilfor an unknown number. -
trap(the same method asSignal.trap;Kernel#trapis the shortcut) accepts the signal as a name with or withoutSIG, a symbol or a number, and rejects names the system does not have (ArgumentError: unsupported signal). -
Signals that Ruby itself needs are reserved:
SEGV,BUS,ILL,FPEandVTALRMraiseArgumentError(can't trap reserved signal).KILLandSTOPraiseErrno::EINVALfrom the system call. -
trapreturns the previous handler: aProc, or one of the strings"DEFAULT","IGNORE","SYSTEM_DEFAULT"or"EXIT". Save it to restore it later (trap("INT", old)).
| Handler argument | Meaning |
|---|---|
a block or a |
Ruby code to run; it receives the signal number. |
|
Ruby’s default behaviour (for |
|
Ignore the signal. |
|
The operating system’s default action, bypassing Ruby. |
|
The process is terminated by the signal. |
Sending signals
Process.kill(signal, *pids) sends a signal and returns the number of processes it reached.
-
The signal is a name, a symbol or a number;
0sends nothing and tests whether the process exists and can be signalled (Process.kill(0, pid)returns 1 or raises). -
A negative pid targets a process group (
Process.kill("TERM", -pgid)), see Process groups. -
Errno::ESRCHmeans no such process;Errno::EPERMmeans you may not signal it.
Default behaviour: Interrupt and SignalException
# frozen_string_literal: true
old = trap("INT") { |signo| puts "caught signal #{signo} (#{Signal.signame(signo)})" }
p old
Process.kill("INT", Process.pid)
sleep 0.1
Process.kill("INT", Process.pid)
sleep 0.1
trap("INT", old)
begin
Process.kill("INT", Process.pid)
sleep 1
rescue Interrupt => e
puts "default handler: #{e.class}: #{e.message.inspect}"
p e.signo
p e.is_a?(SignalException)
end
begin
Process.kill("TERM", Process.pid)
sleep 1
rescue SignalException => e
puts "#{e.class}: #{e.message}"
p e.signo
p e.signm
end
e = SignalException.new("HUP")
p e.signo
p SignalException.new(2).message
p Interrupt.new.message
trap("EXIT") { puts "EXIT trap runs first" }
trap("TERM", "IGNORE")
Process.kill("TERM", Process.pid)
sleep 0.1
puts "TERM was ignored"
at_exit { puts "at_exit handlers run after it" }
Output:
"DEFAULT"
caught signal 2 (INT)
caught signal 2 (INT)
default handler: Interrupt: ""
2
true
SignalException: SIGTERM
15
"SIGTERM"
1
"SIGINT"
"Interrupt"
TERM was ignored
EXIT trap runs first
at_exit handlers run after it
Source: Kernel#trap
-
With no handler, SIGINT (Ctrl-C) raises
Interruptin the main thread and SIGTERM raisesSignalExceptionwith messageSIGTERM; both are subclasses ofSignalException, which is not aStandardError, so a barerescuedoes not catch them and the process ends with the signal’s status.Interruptis the exception behind the "Ctrl-C" traceback; scripts that should exit quietly rescue it at the top level. -
SignalException#signois the number andsignmthe name, and aSignalExceptioncan be built from a name or a number.Interrupt.new.messageis"Interrupt". -
The
"EXIT"trap (alsotrap(0)) runs when the process ends. The output shows that it runs before the handlers registered withat_exit(which run last-registered first). -
The pid of the process is
Process.pid, also available as$$.
Writing a handler: what is safe
A signal can arrive at any instruction. Ruby does not run your handler inside the C-level signal handler: the C handler only records the signal, and the VM runs your block later in the main thread at a safe point between Ruby instructions. That is much safer than in C, but the handler interrupts whatever the main thread was doing, so the Ruby documentation ("Caveats for implementing Signal.trap callbacks") says the code must be reentrant.
# frozen_string_literal: true
lock = Mutex.new
trap("USR1") do
lock.synchronize { puts "never printed" }
rescue ThreadError => e
puts "#{e.class}: #{e.message}"
end
Process.kill("USR1", Process.pid)
sleep 0.1
log = []
trap("USR1") { log << "handled" }
Process.kill("USR1", Process.pid)
sleep 0.1
p log
trap("USR1") { Thread.new { puts "logging from a helper thread" }.join }
Process.kill("USR1", Process.pid)
sleep 0.1
trap("TERM") { exit 3 }
begin
Process.kill("TERM", Process.pid)
sleep 1
rescue SystemExit => e
puts "SystemExit status #{e.status}"
end
trap("USR2") { raise "raised in a trap" }
begin
Process.kill("USR2", Process.pid)
sleep 1
rescue RuntimeError => e
puts "main thread received: #{e.message}"
end
t = Thread.new { sleep 1 }
trap("USR1") { puts "handler ran in main thread: #{Thread.current == Thread.main}" }
Process.kill("USR1", Process.pid)
sleep 0.1
t.kill
Output:
ThreadError: can't be called from trap context
["handled"]
logging from a helper thread
SystemExit status 3
main thread received: raised in a trap
handler ran in main thread: true
Source: Signals
The rules, from the documentation and the output:
-
No
Mutex.Mutex#lockandsynchronizeraiseThreadError: can't be called from trap context, because the main thread might already hold the lock.Monitorand any library that uses a mutex fail the same way. The common surprise isLogger#infoin a handler: theLoggerlibrary catches the error, printslog writing failed. can't be called from trap contexton standard error and drops the message. -
Avoid buffered writes unless
IO#syncis true. Pipes and sockets are synchronous by default. -
Safe: assigning to variables, small object allocation,
Array,Hash,Stringoperations that run no block,Queue#pushandSizedQueue#push,Process.kill,Process.wait,IO.pipe,IO#read,File.openand many other system-call wrappers. -
The trick for unsafe work: start a short-lived thread (
Thread.new { logger.warn(...) }.join) or hand the work to the main loop. The helper thread may lock mutexes, because it is an ordinary thread. -
exitinside a handler raisesSystemExitin the main thread, which unwinds normally and runsensureblocks andat_exit. Araisein the handler is raised in the main thread at the point it was interrupted. -
The handler always runs in the main thread, even when other threads exist and even if they received the signal in the C sense.
-
Traps are process-wide, not per thread.
Graceful shutdown with a flag
The simplest correct pattern: the handler sets a flag and the main loop checks it.
# frozen_string_literal: true
# A worker loop that shuts down cleanly on SIGINT or SIGTERM.
class Worker
def initialize
@stop = false
@done = []
end
attr_reader :done
def stop!
@stop = true
end
def run
trap("INT") { stop! }
trap("TERM") { stop! }
until @stop
@done << @done.size
sleep 0.01
end
ensure
trap("INT", "DEFAULT")
trap("TERM", "DEFAULT")
end
end
worker = Worker.new
Thread.new do
sleep 0.1
Process.kill("TERM", Process.pid)
end
worker.run
puts "stopped after #{worker.done.size > 3 ? 'several' : 'few'} iterations"
Output:
stopped after several iterations
Source: Signal.trap
The loop is not stopped in the middle of an iteration, so the current unit of work ends cleanly, ensure restores the default handlers and a second signal can then kill the process. Make the sleeping inside the loop short, or use the self-pipe below, so the flag is noticed quickly.
The self-pipe
When the main thread is blocked waiting for I/O, a flag is not noticed until the call returns. The self-pipe trick turns the signal into an I/O event: the handler writes one byte to a pipe, and the main loop waits on the pipe (together with anything else it waits for, with IO.select or wait_readable).
# frozen_string_literal: true
# The self-pipe trick: the handler only writes one byte, the main loop does the work.
reader, writer = IO.pipe
queue = Queue.new
%w[HUP TERM].each do |name|
trap(name) { writer.write_nonblock(name[0]) }
end
Thread.new do
sleep 0.05
Process.kill("HUP", Process.pid)
sleep 0.05
Process.kill("TERM", Process.pid)
end
loop do
next unless reader.wait_readable(1)
case reader.read_nonblock(1)
when "H" then queue << "reload configuration"
when "T"
queue << "shut down"
break
end
end
puts queue.pop until queue.empty?
Output:
reload configuration
shut down
Source: IO#wait_readable
write_nonblock is used because it cannot block the handler when the pipe is full (a lost byte is harmless when the meaning of the byte is the same). This is how Unicorn-style servers and Queue-based supervisors react to HUP, USR1 and TERM. Use Thread::Queue instead of a pipe if all that is needed is to wake a thread: queue << :term is safe in a handler.
SIGCHLD
The kernel sends SIGCHLD to a parent when a child ends. A handler can reap children without blocking:
# frozen_string_literal: true
reaped = Queue.new
trap("CHLD") do
loop do
pid = Process.wait(-1, Process::WNOHANG)
break unless pid
reaped << Process.last_status.exitstatus
end
rescue Errno::ECHILD
nil
end
3.times { |i| spawn("sh", "-c", "exit #{i + 1}") }
sleep 0.01 until reaped.size == 3
codes = Array.new(3) { reaped.pop }
p codes.sort
trap("CHLD", "DEFAULT")
Output:
[1, 2, 3]
Source: Process.wait
Several children that end together produce one delivery, which is why the handler loops with Process::WNOHANG until nothing is left, and why Errno::ECHILD is rescued. Waiting with Queue#pop in the main thread while nothing else is running would make Ruby report No live threads left. Deadlock?, because the signal is the only thing that could wake it and Ruby cannot know that; the example polls with sleep. In most programs Process.wait or Process.detach is simpler than a CHLD handler.
Ctrl-C and process groups
Pressing Ctrl-C in a terminal sends SIGINT to every process of the foreground process group, which includes the children your script started with system, spawn or backticks. So a child usually dies by itself and your script sees both Interrupt and the child’s status. If the child should survive, start it in its own group (pgroup: true) or ignore INT in it. After an unhandled SIGINT Ruby ends the process by the signal itself, so a shell shows status 130 (and 143 after an unhandled SIGTERM); both were checked with ruby -e on this page’s platform.
Killing and time-limiting children
A well-behaved supervisor asks first (TERM), waits for a grace period and only then forces (KILL):
# frozen_string_literal: true
# Stop a child politely, then forcefully, and report what happened.
def stop(pid, grace: 0.5)
Process.kill("TERM", pid)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + grace
loop do
done, status = Process.wait2(pid, Process::WNOHANG)
return status if done
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
sleep 0.01
end
Process.kill("KILL", pid)
Process.wait2(pid).last
end
polite = spawn("sleep", "30")
status = stop(polite)
puts "polite child: #{Signal.signame(status.termsig)}"
stubborn = spawn("sh", "-c", "trap '' TERM; while true; do sleep 1; done")
sleep 0.2
status = stop(stubborn, grace: 0.3)
puts "stubborn child: #{Signal.signame(status.termsig)}"
pid = spawn("sleep", "30")
p Process.kill(0, pid)
Process.kill("TERM", pid)
Process.wait(pid)
begin
Process.kill(0, pid)
rescue Errno::ESRCH => e
puts "#{e.class}: #{e.message}"
end
Output:
polite child: TERM
stubborn child: KILL
1
Errno::ESRCH: No such process
Source: Process.kill
The loop polls with Process::WNOHANG and a monotonic clock (Process.clock_gettime(Process::CLOCK_MONOTONIC), which does not jump when the wall clock changes). Process.kill(0, pid) returns 1 while the process exists and Errno::ESRCH afterwards.
Time limits
# frozen_string_literal: true
require "open3"
require "timeout"
def run_with_limit(*command, seconds:)
Open3.popen2e(*command) do |stdin, out, thread|
stdin.close
unless thread.join(seconds)
Process.kill("TERM", thread.pid)
thread.join
return [:timeout, out.read]
end
[thread.value.exitstatus, out.read]
end
end
p run_with_limit("sh", "-c", "echo quick", seconds: 2)
p run_with_limit("sleep", "30", seconds: 0.2)
pid = spawn("sleep", "30")
begin
Timeout.timeout(0.2) { Process.wait(pid) }
rescue Timeout::Error => e
puts "#{e.class}: #{e.message}"
puts "child still running: #{Process.kill(0, pid) == 1}"
Process.kill("TERM", pid)
Process.wait(pid)
end
Output:
[0, "quick\n"]
[:timeout, ""]
Timeout::Error: execution expired
child still running: true
Source: Timeout
-
Thread#join(seconds)returnsnilon expiry; combine it withProcess.kill("TERM", thread.pid)for a safe limit on anOpen3child (the first call in the example). -
Timeout.timeout(seconds) { ... }raisesTimeout::Errorin the calling thread, which stops waiting for the child but does not stop the child: the last line shows it still alive. Timeouts aroundsystemor backticks therefore leave orphaned children running; kill the pid yourself in the rescue clause. Timeout also interrupts Ruby code at arbitrary points, so it is a poor tool for protecting invariants (see Threads and the GVL).
Process groups
A process group is a set of processes that can be signalled together. A shell makes one group per job. A child that starts its own children (sh -c "a & b & wait") leaves them behind when you kill only its pid; kill the whole group instead.
# frozen_string_literal: true
# A child in its own process group can be signalled together with its children.
pid = spawn("sh", "-c", "sleep 30 & sleep 30 & wait", pgroup: true)
sleep 0.2
p Process.getpgid(pid) == pid
p Process.getpgid(pid) == Process.getpgrp
Process.kill("TERM", -pid)
Process.wait(pid)
p Process.last_status.signaled?
p Process.last_status.equal?(Process.last_status)
begin
Process.getpgid(pid)
rescue Errno::ESRCH => e
puts "#{e.class}: #{e.message}"
end
Output:
true
false
true
true
Errno::ESRCH: No such process
Source: Process.getpgid
spawn(..., pgroup: true) made the child the leader of a new group whose id equals its pid, Process.kill("TERM", -pid) signalled everybody in the group and Process.last_status.signaled? confirms it. Related calls: Process.getpgrp, Process.getsid, Process.setsid (start a new session with no controlling terminal) and Process.setpgid.
Process information
# frozen_string_literal: true
p Process.pid.is_a?(Integer)
p Process.ppid.is_a?(Integer)
p Process.pid == $$ # rubocop:disable Style/SpecialGlobalVars
p Process.uid == Process.euid
p Process.getpgid(0) == Process.getpgrp
p Process.argv0 == $PROGRAM_NAME
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
sleep 0.05
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
p elapsed >= 0.05
p Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond).class
p Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID).class
times = Process.times
p times.class
p times.members
p times.utime.class
Process.setproctitle("shelf-worker")
p $PROGRAM_NAME == "process_info.rb"
p Process.respond_to?(:warmup)
p Process.respond_to?(:daemon)
p Process::Status.instance_methods(false).sort.first(6)
soft, hard = Process.getrlimit(Process::RLIMIT_NOFILE)
p soft.positive?
p hard >= soft
p Process.getpriority(Process::PRIO_PROCESS, 0).class
Output:
true
true
true
true
true
true
true
Integer
Float
Process::Tms
[:utime, :stime, :cutime, :cstime]
Float
true
true
true
[:==, :coredump?, :exited?, :exitstatus, :inspect, :pid]
true
true
Integer
Source: Process
-
Process.pid,Process.ppid,Process.uid,Process.euid,Process.gid,Process.egid,Process.groupsidentify the process and its owner. -
Process.clock_gettime(clock, unit = :float_second)reads a clock:CLOCK_MONOTONICfor measuring durations,CLOCK_REALTIME(same asTime.now),CLOCK_PROCESS_CPUTIME_IDfor CPU time, with units such as:millisecond,:microsecondand:nanosecond. See Time and Dates for the choice between clocks. -
Process.timesreturns aProcess::Tmswith user and system CPU seconds for the process (utime,stime) and its waited-for children (cutime,cstime). -
Process.setproctitle("shelf-worker")changes the name shown byps; it does not change$PROGRAM_NAMEand has no effect on some platforms.Process.argv0is the original program name. -
Process.getrlimitandProcess.setrlimitread and set resource limits (RLIMIT_NOFILE,RLIMIT_CPU,RLIMIT_CORE);Process.getpriorityandProcess.setpriorityread and set scheduling priority. -
Process.warmup(Ruby 3.3) is covered in the fork section;Process.daemonin the same place.
ENV
ENV is a hash-like object (an object with Hash-like methods, not a Hash: ENV.class is Object) whose entries are the real environment variables of the process. Reading a key reads the current environment, setting a key calls the operating system, and children started afterwards inherit what you set.
# frozen_string_literal: true
# rubocop:disable Style/FetchEnvVar
ENV["SHELF_MODE"] = "demo"
p ENV["SHELF_MODE"]
p ENV.fetch("SHELF_MODE")
p ENV["SHELF_MISSING"]
p ENV.fetch("SHELF_MISSING", "fallback")
p ENV.fetch("SHELF_MISSING") { |key| "no #{key}" }
p ENV.key?("SHELF_MODE")
begin
ENV.fetch("SHELF_MISSING")
rescue KeyError => e
puts "#{e.class}: #{e.message}"
end
begin
ENV["SHELF_LIMIT"] = 10
rescue TypeError => e
puts "#{e.class}: #{e.message}"
end
ENV["SHELF_LIMIT"] = 10.to_s
p ENV["SHELF_LIMIT"].to_i
p ENV["SHELF_MODE"].frozen?
p ENV.delete("SHELF_LIMIT")
p ENV.delete("SHELF_LIMIT")
ENV["SHELF_MODE"] = nil
p ENV.key?("SHELF_MODE")
p ENV.class
p ENV.to_h.class
p ENV.slice("HOME", "NO_SUCH_VARIABLE").keys
copy = ENV.to_h
copy["SHELF_MODE"] = "copy"
p ENV["SHELF_MODE"]
ENV["SHELF_MODE"] = "child-visible"
puts `sh -c 'echo "child: $SHELF_MODE"'`
system({ "SHELF_MODE" => "per-call" }, "sh", "-c", 'echo "child: $SHELF_MODE"')
system({ "SHELF_MODE" => nil }, "sh", "-c", 'echo "child: [${SHELF_MODE-unset}]"')
begin
ENV.clone # rubocop:disable Lint/DeprecatedClassMethods
rescue TypeError => e
puts "#{e.class}: #{e.message}"
end
puts ENV.inspect.length.positive?
puts ENV.to_a.first.class
# rubocop:enable Style/FetchEnvVar
Output:
"demo"
"demo"
nil
"fallback"
"no SHELF_MISSING"
true
KeyError: key not found: "SHELF_MISSING"
TypeError: no implicit conversion of Integer into String
10
true
"10"
nil
false
Object
Hash
["HOME"]
nil
child: child-visible
child: per-call
child: [unset]
TypeError: Cannot clone ENV, use ENV.to_h to get a copy of ENV as a hash
true
Array
Source: ENV
Rules to know:
-
Values are strings, always.
ENV["PORT"] = 8080raisesTypeError; convert withto_s, and convert on the way out (ENV["PORT"].to_i; an absent variable isnil, andnil.to_iis 0, a classic source of "port 0"). Names and values must not contain a NUL byte (ArgumentError), names must not be empty or contain=(Errno::EINVAL). -
ENV.fetch("KEY")raisesKeyErrorfor a missing key,ENV.fetch("KEY", default)and the block form give a default. This is the right call for required configuration: fail at start with a clear message instead of passingnilaround. (RuboCop’sStyle/FetchEnvVarsuggests it; the example disables it because it showsENV[].) -
Assigning
nilor callingENV.deleteremoves the variable. -
Returned strings are frozen; dup them before modifying.
-
ENV.to_h(with an optional block) returns a regularHashcopy,ENV.slice("A", "B")a sub-hash;ENV.dupandENV.cloneraiseTypeError. Useto_hfor a snapshot (the example proves the copy is independent). -
ENV.update,ENV.merge!,ENV.replace,ENV.clear,ENV.each,ENV.select,ENV.filter_mapand the otherHash-like methods exist. The iteration order is the order the operating system gives, not insertion order. -
A change to
ENVis visible to child processes that start afterwards, to other threads of the process and (for libraries that callgetenv) to native extensions. A per-call change belongs in the leading hash ofspawn/system/Open3({ "SHELF_MODE" => "per-call" }), which does not touch the parent’s environment;nilremoves the variable in the child. -
On Windows environment names are case-insensitive (
ENV["Path"]andENV["PATH"]are the same), and Ruby may normalise their case; on Unix they are case-sensitive. -
Secrets belong in the environment or in a secret manager, not in source files; they are visible to every child process and, on some systems, to other users through
ps ewwor/proc, so do not pass secrets as arguments. -
The encoding of values is the locale’s (
Encoding.default_externalon Unix), which matters for non-ASCII values (see Encodings and Unicode).
The environment variables that influence Ruby itself (RUBYOPT, RUBYLIB, GEM_HOME, RUBY_THREAD_VM_STACK_SIZE) are in Ruby Command Line and Environment.
Windows and other platforms
The examples ran on macOS; this section restates what the Ruby documentation says about other platforms and was not run on them.
-
There is no
forkon Windows:Process.respond_to?(:fork)isfalseandforkraisesNotImplementedError. Usespawn,Open3, threads or Ractors. -
The
Signaldocumentation says that the list of signal names and their interpretation is system dependent and that delivery may not always be reliable. Do not rely onUSR1,HUPorCHLDon Windows; test withSignal.list. -
A command string is run through
cmd.exe(or the shell named byRUBYSHELL, orCOMSPEC) when it uses acmd.exebuilt-in such asechoor names a.bator.cmdfile, even in the argument-array form.%VAR%expands environment variables there and there is no globbing. The execution option for a new process group isnew_pgroup: true;pgroup:is for Unix. -
Shellwordsimplements POSIX shell quoting only, so it is not a safe way to build acmd.execommand line.
Where older material is outdated
| Older practice | Status in Ruby 4.0 | Today |
|---|---|---|
|
Removed (deprecated in 3.3) |
|
|
Removed (deprecated in 3.3) |
|
|
Removed (the taint API went in 3.2) |
Argument arrays, |
|
Still works |
An array of arguments, |
|
Still works but leaves the child running |
|
|
Unchanged |
Handle |
Reading |
Unchanged |
|
Ruby 1.8 era |
Replaced years ago |
|
Pitfalls checklist
| Pitfall | Remedy |
|---|---|
Interpolating input into a command string |
Pass an argument array; |
|
Use |
Children never waited for |
|
|
Use |
Child waits for input forever |
Close its stdin ( |
|
Set a flag, push to a |
Output lost after |
Flush before it, or use a normal exit. |
|
Fork before starting threads; reopen connections in the child. |
|
Leave the child with |
|
Kill the pid yourself. |
Assuming Ctrl-C reaches only your script |
Children get SIGINT too; use |
Signal numbers hard-coded |
Use names; numbers vary between systems. |
|
Convert with |
Passing secrets as command arguments |
Environment or a protected file instead. |