Class: Ibex::BoundedSubprocess

Inherits:
Object
  • Object
show all
Defined in:
lib/ibex/bounded_subprocess.rb,
sig/ibex/bounded_subprocess.rbs

Overview

Captures one shell-free child process under wall-clock and output budgets.

Defined Under Namespace

Classes: Result

Constant Summary collapse

DEFAULT_TIMEOUT_SECONDS =

Signature:

  • Integer

Returns:

  • (Integer)
10
DEFAULT_MAX_OUTPUT_BYTES =

Signature:

  • Integer

Returns:

  • (Integer)
1_048_576
TERMINATION_GRACE_SECONDS =

Signature:

  • Float

Returns:

  • (Float)
0.25
POLL_SECONDS =

Signature:

  • Float

Returns:

  • (Float)
0.01

Instance Method Summary collapse

Constructor Details

#initialize(timeout_seconds: DEFAULT_TIMEOUT_SECONDS, max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES) ⇒ BoundedSubprocess

Returns a new instance of BoundedSubprocess.

RBS:

  • (timeout_seconds: Integer, max_output_bytes: Integer) -> void

Parameters:

  • timeout_seconds: (Integer) (defaults to: DEFAULT_TIMEOUT_SECONDS)
  • max_output_bytes: (Integer) (defaults to: DEFAULT_MAX_OUTPUT_BYTES)


35
36
37
38
39
40
41
42
# File 'lib/ibex/bounded_subprocess.rb', line 35

def initialize(timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
               max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES)
  raise ArgumentError, "timeout_seconds must be positive" unless timeout_seconds.positive?
  raise ArgumentError, "max_output_bytes must be positive" unless max_output_bytes.positive?

  @timeout_seconds = timeout_seconds
  @max_output_bytes = max_output_bytes
end

Instance Method Details

#monotonic_timeFloat

RBS:

  • () -> Float

Returns:

  • (Float)


167
168
169
# File 'lib/ibex/bounded_subprocess.rb', line 167

def monotonic_time
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
end

#output_exceeded?(stdout_path, stderr_path) ⇒ Boolean

RBS:

  • (String stdout_path, String stderr_path) -> bool

Parameters:

  • stdout_path (String)
  • stderr_path (String)

Returns:

  • (Boolean)


157
158
159
# File 'lib/ibex/bounded_subprocess.rb', line 157

def output_exceeded?(stdout_path, stderr_path)
  File.size(stdout_path) > @max_output_bytes || File.size(stderr_path) > @max_output_bytes
end

#process_group_alive?(pid) ⇒ Boolean

RBS:

  • (Integer pid) -> bool

Parameters:

  • pid (Integer)

Returns:

  • (Boolean)


134
135
136
137
138
139
140
141
# File 'lib/ibex/bounded_subprocess.rb', line 134

def process_group_alive?(pid)
  Process.kill(0, -pid)
  true
rescue Errno::ESRCH
  false
rescue Errno::EPERM
  true
end

#process_groups?Boolean

RBS:

  • () -> bool

Returns:

  • (Boolean)


144
145
146
# File 'lib/ibex/bounded_subprocess.rb', line 144

def process_groups?
  !RUBY_PLATFORM.match?(/mswin|mingw|cygwin/)
end

#process_status(pid) ⇒ Process::Status

RBS:

  • (Integer pid) -> Process::Status

Parameters:

  • pid (Integer)

Returns:

  • (Process::Status)


149
150
151
152
153
154
# File 'lib/ibex/bounded_subprocess.rb', line 149

def process_status(pid)
  waited = Process.waitpid2(pid, Process::WNOHANG)
  return waited.last if waited

  raise Ibex::Error, "(subprocess):1:1: child #{pid} exited without a wait status"
end

#read_bounded(path) ⇒ String

RBS:

  • (String path) -> String

Parameters:

  • path (String)

Returns:

  • (String)


162
163
164
# File 'lib/ibex/bounded_subprocess.rb', line 162

def read_bounded(path)
  File.binread(path, @max_output_bytes)
end

#run(command, input:) ⇒ Result

RBS:

  • (Array[String] command, input: String) -> Result

Parameters:

  • command (Array[String])
  • input: (String)

Returns:



45
46
47
48
49
50
51
52
53
54
55
# File 'lib/ibex/bounded_subprocess.rb', line 45

def run(command, input:)
  raise ArgumentError, "command must not be empty" if command.empty?

  Tempfile.create("ibex-subprocess-input") do |stdin|
    Tempfile.create("ibex-subprocess-stdout") do |stdout|
      Tempfile.create("ibex-subprocess-stderr") do |stderr|
        run_with_files(command, input, stdin, stdout, stderr)
      end
    end
  end
end

#run_with_files(command, input, stdin, stdout, stderr) ⇒ Result

RBS:

  • (Array[String] command, String input, File stdin, File stdout, File stderr) -> Result

Parameters:

  • command (Array[String])
  • input (String)
  • stdin (File)
  • stdout (File)
  • stderr (File)

Returns:



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/ibex/bounded_subprocess.rb', line 60

def run_with_files(command, input, stdin, stdout, stderr)
  stdin.binmode
  stdin.write(input)
  stdin.flush
  stdin.rewind
  pid = spawn_child(command, stdin, stdout, stderr)
  status, timed_out, output_limited = wait(pid, stdout.path, stderr.path)
  terminate_descendants(pid)
  Result.new(
    stdout: read_bounded(stdout.path), stderr: read_bounded(stderr.path),
    status: status, timed_out: timed_out, output_limited: output_limited
  ).freeze
ensure
  terminate(pid) if pid && !status
end

#signal_process_tree(signal, pid) ⇒ void

This method returns an undefined value.

RBS:

  • (String signal, Integer pid) -> void

Parameters:

  • signal (String)
  • pid (Integer)


129
130
131
# File 'lib/ibex/bounded_subprocess.rb', line 129

def signal_process_tree(signal, pid)
  Process.kill(signal, process_groups? ? -pid : pid)
end

#spawn_child(command, stdin, stdout, stderr) ⇒ Integer

RBS:

  • (Array[String] command, File stdin, File stdout, File stderr) -> Integer

Parameters:

  • command (Array[String])
  • stdin (File)
  • stdout (File)
  • stderr (File)

Returns:

  • (Integer)


77
78
79
80
81
# File 'lib/ibex/bounded_subprocess.rb', line 77

def spawn_child(command, stdin, stdout, stderr)
  options = { in: stdin, out: stdout, err: stderr } #: Hash[Symbol, Object?]
  options[:pgroup] = true if process_groups?
  spawn(*command, **options)
end

#terminate(pid) ⇒ Process::Status

RBS:

  • (Integer pid) -> Process::Status

Parameters:

  • pid (Integer)

Returns:

  • (Process::Status)


99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/ibex/bounded_subprocess.rb', line 99

def terminate(pid)
  signal_process_tree("TERM", pid)
  deadline = monotonic_time + TERMINATION_GRACE_SECONDS
  loop do
    waited = Process.waitpid2(pid, Process::WNOHANG)
    return waited.last if waited
    break if monotonic_time >= deadline

    sleep(POLL_SECONDS)
  end
  signal_process_tree("KILL", pid)
  Process.waitpid2(pid).last
rescue Errno::ESRCH, Errno::ECHILD
  process_status(pid)
end

#terminate_descendants(pid) ⇒ void

This method returns an undefined value.

RBS:

  • (Integer pid) -> void

Parameters:

  • pid (Integer)


116
117
118
119
120
121
122
123
124
125
126
# File 'lib/ibex/bounded_subprocess.rb', line 116

def terminate_descendants(pid)
  return unless process_groups?
  return unless process_group_alive?(pid)

  deadline = monotonic_time + TERMINATION_GRACE_SECONDS
  Process.kill("TERM", -pid)
  sleep(POLL_SECONDS) while process_group_alive?(pid) && monotonic_time < deadline
  Process.kill("KILL", -pid) if process_group_alive?(pid)
rescue Errno::ESRCH
  nil
end

#wait(pid, stdout_path, stderr_path) ⇒ [ Process::Status, bool, bool ]

RBS:

  • (Integer pid, String stdout_path, String stderr_path) -> [Process::Status, bool, bool]

Parameters:

  • pid (Integer)
  • stdout_path (String)
  • stderr_path (String)

Returns:

  • ([ Process::Status, bool, bool ])


85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/ibex/bounded_subprocess.rb', line 85

def wait(pid, stdout_path, stderr_path)
  deadline = monotonic_time + @timeout_seconds
  loop do
    waited = Process.waitpid2(pid, Process::WNOHANG)
    return [waited.last, false, false] if waited

    return [terminate(pid), false, true] if output_exceeded?(stdout_path, stderr_path)
    return [terminate(pid), true, false] if monotonic_time >= deadline

    sleep(POLL_SECONDS)
  end
end