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 %x(cmd)

The child’s standard output as a String

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.

system

true, false or nil

Inherited: it appears on the parent’s terminal

You want to run a command, show its output and check success.

exec

Does not return

Inherited

You want to replace the current process with another program (wrappers, launchers).

spawn and Process.wait

A pid, immediately

Inherited, or redirected with options

You need the child to run in the background, several children at once or precise redirection.

IO.popen

An IO connected to the child’s stdin or stdout

Read or written through the IO

You stream data to or from one child.

Open3

Output, error and status; or three IO objects and a thread

Stdout and stderr captured separately

You need both streams, the exit status and, usually, the safest default.

fork

The child’s pid in the parent, nil in the child

Inherited

You want a copy of the running Ruby process (Unix only).

flowchart TD A["Need to run an external program"] --> B{"Need its output?"} B -->|"no, just run it"| C{"Wait for it?"} C -->|"yes"| S["system with an argument array"] C -->|"no, keep working"| P["spawn, then Process.wait or Process.detach"] B -->|"yes"| D{"Need stderr or the status as well?"} D -->|"no, stdout text is enough"| K["backticks with trusted text only"] D -->|"yes"| O["Open3.capture3 or capture2e"] O --> Q{"Stream a lot of data or talk interactively?"} Q -->|"yes"| R["Open3.popen3 with a block, or IO.popen"] A --> X{"Copy of this Ruby process?"} X -->|"yes, Unix"| F["fork"] X -->|"replace this process"| E["exec"]

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
""

What the example shows:

  • Only standard output is captured. The line to-stderr went 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_STATUS from the English library, which is why the examples use Process.last_status). The exit status 3 above is silent unless the program looks.

  • A command that cannot be started at all (not found on PATH, not executable) raises Errno::ENOENT (or Errno::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.capture2 or IO.popen with an array when an argument comes from outside.

  • The block Process.last_status is 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

true

The command ran and exited with status 0.

false

The command ran and exited with a non-zero status (Process.last_status.exitstatus has it).

nil

The command could not be run at all (not found, not executable). Process.last_status.exitstatus is 127.

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 Hash sets or removes environment variables in the child only (a nil value removes the variable). The parent’s ENV is 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 Hash carries execution options shared with spawn and exec: chdir:, unsetenv_others:, out:, err:, in:, umask:, pgroup: and others, documented in Execution options. The example uses err: File::NULL to discard the child’s standard error, unsetenv_others: true to start the child with an empty environment (so $HOME is empty) and out: :err to 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 as exit or if, 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. So system("echo hi") has no shell involved, while system("echo hi; echo there") has one.

  • With several strings, Ruby never uses a shell: the first string is the program, looked up in PATH when it contains no slash, and the rest are its arguments, passed unchanged as separate argv entries. 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 own argv[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

Reading the output in order:

  1. system("no-such-program-xyz with args") has no meta-characters, so it is executed directly and returns nil; the status is 127.

  2. 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 is true. A shell string can hide a failure that the array form reports.

  3. system("no-such-program-xyz", "x", exception: false) is nil. In "no-such-program-xyz | cat" the status of a pipeline is the status of its last command (cat), so the line is true. Add set -o pipefail inside an explicit sh -c (or use Open3.pipeline, Open3) when every stage matters.

  4. The explicit sh -c "no-such-program-xyz" returns the shell’s 127.

  5. A file without the execute bit gives nil with status 127 and, with exception: true, Errno::EACCES.

  6. A child killed by a signal has no exit code: exitstatus is nil, signaled? is true and termsig is 2 (SIGINT). The shell that ran it would report 128 + 2 = 130, which is what the inner shell printed.

  7. Process.last_status is the same object as $? and is stored per thread: the thread that ran sh -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

exitstatus

The exit code, or nil if the process was killed by a signal.

success?

true for exit code 0, false for any other code, nil if the process has not exited normally (for example it was killed by a signal).

exited?

true if the process ended by itself (calling exit, returning from main).

signaled? and termsig

true if a signal killed it, and which signal number. Signal.signame(termsig) turns the number into a name.

stopped? and stopsig

Only for children waited for with Process::WUNTRACED: the process was stopped (for example by SIGSTOP), not ended.

coredump?

true if the process dumped core.

pid

The child’s process id.

to_i

The raw wait status word: the exit code shifted left by 8 bits (7 becomes 1792). Prefer exitstatus.

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 sysexits.h family (64 usage error, 65 bad data, 66 no input, 69 unavailable, 73 cannot create, 74 I/O error, 78 configuration error). The numbering is documented on BSD and by the sysexits convention, not by Ruby.

126

The command was found but cannot be executed.

127

The command was not found (what a shell reports, and what Ruby’s system sets in Process.last_status for a failed direct start).

128 + n

The shell’s report that a child died from signal n: 130 for Ctrl-C (SIGINT, 2), 137 for SIGKILL (9), 143 for SIGTERM (15). Ruby itself keeps the signal in termsig and leaves exitstatus nil.

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

Process.wait(pid = -1, flags = 0) and Process.waitpid

Blocks until the given child (or any child with -1) ends, returns its pid and sets Process.last_status. Raises Errno::ECHILD when there is no such child.

Process.wait2 and Process.waitpid2

Same, but returns [pid, status].

Process.waitall

Waits for all children and returns an array of [pid, status] pairs.

Process::WNOHANG flag

Returns nil immediately if the child is still running (the example shows Process.wait(pid, Process::WNOHANG) returning nil), so a loop can poll.

Process::WUNTRACED flag

Also returns for children that are stopped, not only ended.

Process.detach(pid)

Starts a background thread that waits for the child and returns a Process::Waiter (a Thread); waiter.value is the Process::Status. Use it for fire-and-forget children.

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

chdir:

Working directory of the child; the parent’s directory is unchanged.

unsetenv_others: true

Start the child with an empty environment, then apply the leading env hash.

umask:

File-creation mask of the child.

pgroup: true or a pgid

Put the child in a new process group (or an existing one); see Process groups. new_pgroup: true is the Windows equivalent.

in:, out:, err:

Redirect a standard stream (the source) to a destination: a path (opened for reading or writing), [path], [path, mode], [path, mode, perm], an IO or an integer file descriptor, :close to close it, or [:child, fd] to point it at where another descriptor of the child was redirected (err: %i[child out] is the 2>&1 of Ruby).

[:out, :err] => path

An array key redirects several descriptors to one destination.

close_others: true

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.

rlimit_cpu:, rlimit_nofile:, …​

Resource limits (RLIMIT_ name in lower case) as one integer or a [soft, hard] pair.

# 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

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 with system, a leading Hash sets the child’s environment and a trailing Hash carries execution options. err: %i[child out] merges the child’s stderr into the stream that read returns.

  • In "r+" mode the parent writes, calls close_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 with nil. It is Unix-only, like fork.

  • Reading from a child while the child is blocked writing to a different, full pipe is a deadlock, and popen gives you only one pipe in each direction; use Open3 when 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

Kernel#open ran the command silently; IO.read ran it too. No warning.

3.3 and 3.4

Still ran the command, with the warning Calling Kernel#open with a leading '|' is deprecated and will be removed in Ruby 4.0; use IO.popen instead (and the corresponding warning for IO.read and friends). The warning is a deprecation warning, shown with -w or -W:deprecated.

4.0

Raises Errno::ENOENT (the pipe is part of the file name). File.read never had the behaviour, because File class methods skip it.

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

Open3.capture3(cmd, stdin_data: "", binmode: false)

[stdout, stderr, status]; strings are complete once it returns.

Open3.capture2

[stdout, status]; the child’s stderr is inherited.

Open3.capture2e

[stdout_and_stderr, status]: the two streams merged in the order written.

Open3.popen3, popen2, popen2e

Without a block: the IO objects and a wait thread. With a block: yields stdin, stdout, stderr, wait_thr (popen2 has no stderr, popen2e merges them) and closes everything and waits when the block ends. wait_thr.value is the Process::Status, wait_thr.pid the pid.

Open3.pipeline, pipeline_r, pipeline_w, pipeline_rw, pipeline_start

Connect several commands with pipes, like a | b | c: pipeline returns the array of statuses; the _r, _w, _rw variants hand back the first input or the last output; pipeline_start returns immediately with the threads.

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

Reading the output:

  • In the first block the semicolon ended the echo and a second command, echo INJECTED, ran. A real attacker would not print a word.

  • The array form passed the whole hostile text to echo as one argument. Nothing was executed.

  • Shellwords.escape makes 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 of cmd.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 returned true, the second false because ls found no file called -n.

A checklist that follows from the documentation:

  1. Prefer Open3 or system with an argument array. Never build the command with "#{...}" from untrusted data.

  2. Do not use a shell for what Ruby can do itself: use File, FileUtils, Dir and Pathname instead of rm, cp, ls and find; and the standard libraries instead of curl.

  3. If a shell is unavoidable, escape every interpolated piece with Shellwords.escape (or build the whole line with Shellwords.join), and treat the result as a last resort.

  4. Keep PATH trusted. A relative or user-writable PATH lets an attacker supply a program with the name you call; give an absolute path for sensitive programs.

  5. 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).

  6. Never run eval or Kernel#send on 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, shellwords and shellsplit (and the String#shellsplit extension) turn a command line into the argument array that system wants: the array form can then be used with text that someone typed in a shell-like syntax. Unterminated quotes raise ArgumentError.

  • Shellwords.escape, shellescape (and String#shellescape) quote one word; an empty string becomes '' and a newline is wrapped in single quotes.

  • Shellwords.join, shelljoin (and Array#shelljoin) escape and join an array into one command line, the inverse of split.

  • It speaks the Bourne-shell dialect only. For cmd.exe or 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 counter in the child is invisible to the parent. To send data back, use a pipe (IO.pipe, as above), a file, a socket or Marshal over a pipe.

  • exit 5 in the child gives the parent status 5.

  • at_exit handlers are inherited and run in the child at a normal exit (at_exit in child is printed), and then again in the parent. A child that must not run the parent’s handlers leaves with exit!, which skips them (and ensure blocks).

  • 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 that fork calls; 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:

  1. Fork before starting threads, or recreate them in the child.

  2. Close or reopen network connections and database handles in the child; two processes writing on one socket interleave bytes.

  3. Flush buffered output before forking, or the buffer is written twice (once by each process).

  4. Leave a child with exit! after flushing when it must not run the parent’s at_exit handlers.

# 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:

sequenceDiagram participant P as Parent participant C as Child P->>C: fork (copy of the process) Note over C: child runs the block or calls exec C-->>P: SIGCHLD when it ends Note over C: zombie until the parent waits P->>C: Process.wait collects the status Note over P: Process.last_status holds the result

Platform limits of fork

  • Windows has no fork. Process.respond_to?(:fork) is false there and calling fork raises NotImplementedError. Use spawn, Open3 or threads, or a Ractor (Ractors) for in-process parallelism.

  • Other Ruby implementations may not provide fork either, so portable libraries test Process.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).

  • spawn does not carry these problems, because it starts a fresh program with exec instead 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.list maps names to numbers ("EXIT" is 0, a pseudo-signal Ruby uses for the process end). Signal.signame(number) maps back and returns nil for an unknown number.

  • trap (the same method as Signal.trap; Kernel#trap is the shortcut) accepts the signal as a name with or without SIG, 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, FPE and VTALRM raise ArgumentError (can't trap reserved signal). KILL and STOP raise Errno::EINVAL from the system call.

  • trap returns the previous handler: a Proc, 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 Proc

Ruby code to run; it receives the signal number.

"DEFAULT" or "SIG_DFL"

Ruby’s default behaviour (for INT it raises Interrupt, for TERM SignalException).

"IGNORE", "SIG_IGN", "" or nil

Ignore the signal.

"SYSTEM_DEFAULT"

The operating system’s default action, bypassing Ruby.

"EXIT"

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; 0 sends 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::ESRCH means no such process; Errno::EPERM means 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 Interrupt in the main thread and SIGTERM raises SignalException with message SIGTERM; both are subclasses of SignalException, which is not a StandardError, so a bare rescue does not catch them and the process ends with the signal’s status. Interrupt is the exception behind the "Ctrl-C" traceback; scripts that should exit quietly rescue it at the top level.

  • SignalException#signo is the number and signm the name, and a SignalException can be built from a name or a number. Interrupt.new.message is "Interrupt".

  • The "EXIT" trap (also trap(0)) runs when the process ends. The output shows that it runs before the handlers registered with at_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.

flowchart LR K["Kernel delivers the signal"] --> C["Tiny C handler records it"] C --> M["Main thread reaches a safe point"] M --> H["Ruby runs the trap block, in the main thread"] H --> R["Main thread resumes where it was"]
# 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#lock and synchronize raise ThreadError: can't be called from trap context, because the main thread might already hold the lock. Monitor and any library that uses a mutex fail the same way. The common surprise is Logger#info in a handler: the Logger library catches the error, prints log writing failed. can't be called from trap context on standard error and drops the message.

  • Avoid buffered writes unless IO#sync is true. Pipes and sockets are synchronous by default.

  • Safe: assigning to variables, small object allocation, Array, Hash, String operations that run no block, Queue#push and SizedQueue#push, Process.kill, Process.wait, IO.pipe, IO#read, File.open and 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.

  • exit inside a handler raises SystemExit in the main thread, which unwinds normally and runs ensure blocks and at_exit. A raise in 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

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) returns nil on expiry; combine it with Process.kill("TERM", thread.pid) for a safe limit on an Open3 child (the first call in the example).

  • Timeout.timeout(seconds) { ... } raises Timeout::Error in the calling thread, which stops waiting for the child but does not stop the child: the last line shows it still alive. Timeouts around system or 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.groups identify the process and its owner.

  • Process.clock_gettime(clock, unit = :float_second) reads a clock: CLOCK_MONOTONIC for measuring durations, CLOCK_REALTIME (same as Time.now), CLOCK_PROCESS_CPUTIME_ID for CPU time, with units such as :millisecond, :microsecond and :nanosecond. See Time and Dates for the choice between clocks.

  • Process.times returns a Process::Tms with 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 by ps; it does not change $PROGRAM_NAME and has no effect on some platforms. Process.argv0 is the original program name.

  • Process.getrlimit and Process.setrlimit read and set resource limits (RLIMIT_NOFILE, RLIMIT_CPU, RLIMIT_CORE); Process.getpriority and Process.setpriority read and set scheduling priority.

  • Process.warmup (Ruby 3.3) is covered in the fork section; Process.daemon in 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"] = 8080 raises TypeError; convert with to_s, and convert on the way out (ENV["PORT"].to_i; an absent variable is nil, and nil.to_i is 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") raises KeyError for 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 passing nil around. (RuboCop’s Style/FetchEnvVar suggests it; the example disables it because it shows ENV[].)

  • Assigning nil or calling ENV.delete removes the variable.

  • Returned strings are frozen; dup them before modifying.

  • ENV.to_h (with an optional block) returns a regular Hash copy, ENV.slice("A", "B") a sub-hash; ENV.dup and ENV.clone raise TypeError. Use to_h for a snapshot (the example proves the copy is independent).

  • ENV.update, ENV.merge!, ENV.replace, ENV.clear, ENV.each, ENV.select, ENV.filter_map and the other Hash-like methods exist. The iteration order is the order the operating system gives, not insertion order.

  • A change to ENV is visible to child processes that start afterwards, to other threads of the process and (for libraries that call getenv) to native extensions. A per-call change belongs in the leading hash of spawn/system/Open3 ({ "SHELF_MODE" => "per-call" }), which does not touch the parent’s environment; nil removes the variable in the child.

  • On Windows environment names are case-insensitive (ENV["Path"] and ENV["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 eww or /proc, so do not pass secrets as arguments.

  • The encoding of values is the locale’s (Encoding.default_external on 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 fork on Windows: Process.respond_to?(:fork) is false and fork raises NotImplementedError. Use spawn, Open3, threads or Ractors.

  • The Signal documentation says that the list of signal names and their interpretation is system dependent and that delivery may not always be reliable. Do not rely on USR1, HUP or CHLD on Windows; test with Signal.list.

  • A command string is run through cmd.exe (or the shell named by RUBYSHELL, or COMSPEC) when it uses a cmd.exe built-in such as echo or names a .bat or .cmd file, even in the argument-array form. %VAR% expands environment variables there and there is no globbing. The execution option for a new process group is new_pgroup: true; pgroup: is for Unix.

  • Shellwords implements POSIX shell quoting only, so it is not a safe way to build a cmd.exe command line.

Where older material is outdated

Older practice Status in Ruby 4.0 Today

open("|command"), IO.read("|command") and similar

Removed (deprecated in 3.3)

IO.popen([...]), Open3.capture2.

$? >> 8, $? & 0xff, Process::Status#&

Removed (deprecated in 3.3)

Process.last_status.exitstatus, termsig.

$SAFE and tainting to defend against injection

Removed (the taint API went in 3.2)

Argument arrays, Shellwords, validation.

system or backticks with a string for everything

Still works

An array of arguments, Open3, exception: true.

Timeout.timeout around a child

Still works but leaves the child running

Thread#join(seconds) plus Process.kill.

Process.wait without rescue Errno::ECHILD

Unchanged

Handle Errno::ECHILD or track pids.

Reading ENV["X"] for required settings

Unchanged

ENV.fetch("X").

Ruby 1.8 era popen3 in the Open3 documentation: no wait thread

Replaced years ago

popen3 yields a wait thread, capture3 is simpler.

Pitfalls checklist

Pitfall Remedy

Interpolating input into a command string

Pass an argument array; Shellwords.escape only as a last resort.

system returns nil and the code treats it as success

Use exception: true or test nil.

Children never waited for

Process.wait or Process.detach.

popen3 read of stdout before stderr

Use Open3.capture3, or threads.

Child waits for input forever

Close its stdin (stdin.close, close_write).

Mutex or Logger in a trap block

Set a flag, push to a Queue or use a helper thread.

Output lost after exit!

Flush before it, or use a normal exit.

fork with threads, sockets and database handles

Fork before starting threads; reopen connections in the child.

at_exit handlers run in the forked child too

Leave the child with exit! after flushing.

Timeout does not kill the child it waits for

Kill the pid yourself.

Assuming Ctrl-C reaches only your script

Children get SIGINT too; use pgroup: true when they should not.

Signal numbers hard-coded

Use names; numbers vary between systems.

ENV["X"] = number

Convert with to_s.

Passing secrets as command arguments

Environment or a protected file instead.