Class: LittleGhost::Sandbox::ProcessSession
- Inherits:
-
Object
- Object
- LittleGhost::Sandbox::ProcessSession
- Defined in:
- lib/little_ghost/sandbox/process_session.rb
Overview
Owns one sandboxed child process and its bounded input and output streams.
Timeout, cancellation, and close terminate the original process group and
its ordinary descendants. A descendant that creates another process group
can outlive this session. Use a backend with process_tree_ownership or an
outer supervisor when complete descendant ownership is required.
When memory_bytes is configured, the parent samples the visible process
tree every 100 milliseconds. This guard may miss memory peaks between
samples. On Linux, three consecutive failures to read the root process or
the /proc snapshot end the process. Use an outer cgroup or container when
memory needs a hard kernel-enforced limit.
Defined Under Namespace
Classes: Chunk
Instance Attribute Summary collapse
-
#pid ⇒ Object
readonly
Operating-system process ID of the command process.
Instance Method Summary collapse
-
#alive? ⇒ Boolean
Whether the command process or its original process group is still alive.
-
#close ⇒ Object
Terminates the process when needed and closes every owned stream.
-
#close_write ⇒ Object
Closes the child's standard input without ending the process.
-
#initialize(command:, environment: {}, inherit_environment: false, chdir: nil, output_bytes: 1_000_000, memory_bytes: nil, memory_reader: nil, cpu_seconds: nil, file_bytes: nil) ⇒ ProcessSession
constructor
Starts
commandin a new process group with a scrubbed environment by default. -
#read(timeout: 0) ⇒ Object
Reads currently available output, waiting for at most
timeoutseconds. -
#terminate ⇒ Object
Requests termination, forces it when needed, and returns the child's Process::Status when available.
-
#wait(timeout: nil, context: nil, terminate: true) ⇒ Object
Waits for completion and returns the child's Process::Status.
-
#write(value) ⇒ Object
Writes
valueto the child's standard input.
Constructor Details
#initialize(command:, environment: {}, inherit_environment: false, chdir: nil, output_bytes: 1_000_000, memory_bytes: nil, memory_reader: nil, cpu_seconds: nil, file_bytes: nil) ⇒ ProcessSession
Starts command in a new process group with a scrubbed environment by
default. output_bytes bounds combined standard output and error.
Optional CPU, file-size, and sampled-memory limits apply to the child.
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
# File 'lib/little_ghost/sandbox/process_session.rb', line 26 def initialize(command:, environment: {}, inherit_environment: false, chdir: nil, output_bytes: 1_000_000, memory_bytes: nil, memory_reader: nil, cpu_seconds: nil, file_bytes: nil) @output_bytes = Integer(output_bytes) raise ArgumentError, "output_bytes must be positive" unless @output_bytes.positive? @memory_bytes = memory_bytes && Integer(memory_bytes) @memory_reader = memory_reader || default_memory_reader if @memory_bytes @stdin_r, @stdin_w = IO.pipe @stdout_r, @stdout_w = IO.pipe @stderr_r, @stderr_w = IO.pipe = { in: @stdin_r, out: @stdout_w, err: @stderr_w, pgroup: true, unsetenv_others: !inherit_environment } [:chdir] = chdir if chdir [:rlimit_cpu] = [Integer(cpu_seconds), Integer(cpu_seconds)] if cpu_seconds [:rlimit_fsize] = [Integer(file_bytes), Integer(file_bytes)] if file_bytes @pid = Process.spawn( environment.transform_keys(&:to_s).transform_values(&:to_s), *Array(command).map(&:to_s), ** ) @stdin_r.close @stdout_w.close @stderr_w.close @captured_bytes = 0 @status = nil @closed = false @reap_mutex = Mutex.new @write_mutex = Mutex.new @memory_monitor = Thread.new { monitor_memory } if @memory_bytes rescue [@stdin_r, @stdin_w, @stdout_r, @stdout_w, @stderr_r, @stderr_w].compact.each do |io| io.close unless io.closed? rescue IOError nil end raise end |
Instance Attribute Details
#pid ⇒ Object (readonly)
Operating-system process ID of the command process.
70 71 72 |
# File 'lib/little_ghost/sandbox/process_session.rb', line 70 def pid @pid end |
Instance Method Details
#alive? ⇒ Boolean
Whether the command process or its original process group is still alive. Raises when resource supervision failed.
74 75 76 77 78 |
# File 'lib/little_ghost/sandbox/process_session.rb', line 74 def alive? raise @resource_error if @resource_error raw_alive? end |
#close ⇒ Object
Terminates the process when needed and closes every owned stream.
Calling close more than once is safe.
160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/little_ghost/sandbox/process_session.rb', line 160 def close return if @closed terminate if raw_alive? @closed = true @memory_monitor&.kill unless @memory_monitor.equal?(Thread.current) [@stdin_w, @stdout_r, @stderr_r].each { |io| io.close unless io.closed? } nil rescue IOError, ToolError signal_group("KILL") if @pid nil end |
#close_write ⇒ Object
Closes the child's standard input without ending the process.
93 94 95 |
# File 'lib/little_ghost/sandbox/process_session.rb', line 93 def close_write @stdin_w.close unless @stdin_w.closed? end |
#read(timeout: 0) ⇒ Object
Reads currently available output, waiting for at most timeout seconds.
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
# File 'lib/little_ghost/sandbox/process_session.rb', line 98 def read(timeout: 0) deadline = monotonic_time + Float(timeout) stdout = +"" stderr = +"" loop do readers = [@stdout_r, @stderr_r].reject(&:closed?) break if readers.empty? remaining = [deadline - monotonic_time, 0].max ready = IO.select(readers, nil, nil, remaining) break unless ready ready.first.each do |io| chunk = io.read_nonblock(16_384, exception: false) if chunk.nil? io.close elsif chunk != :wait_readable consume!(chunk) (io.equal?(@stdout_r) ? stdout : stderr) << chunk end end break if timeout.to_f.zero? break if monotonic_time >= deadline end Chunk.new(stdout:, stderr:, eof: !alive? && [@stdout_r, @stderr_r].all?(&:closed?)) end |
#terminate ⇒ Object
Requests termination, forces it when needed, and returns the child's Process::Status when available.
145 146 147 148 149 150 151 152 153 154 155 156 |
# File 'lib/little_ghost/sandbox/process_session.rb', line 145 def terminate return @status unless @pid if process_group_alive? signal_group("TERM") deadline = monotonic_time + 0.5 sleep(0.01) while process_group_alive? && monotonic_time < deadline signal_group("KILL") if process_group_alive? end reap(true) @status end |
#wait(timeout: nil, context: nil, terminate: true) ⇒ Object
Waits for completion and returns the child's Process::Status. When
terminate is true, expiry stops the whole process group before raising.
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/little_ghost/sandbox/process_session.rb', line 127 def wait(timeout: nil, context: nil, terminate: true) deadline = timeout && monotonic_time + Float(timeout) while alive? context&.check! if deadline && monotonic_time >= deadline self.terminate if terminate raise ToolError, "Program timed out after #{timeout} seconds" end sleep(0.01) end @status rescue self.terminate if terminate raise end |
#write(value) ⇒ Object
Writes value to the child's standard input.
81 82 83 84 85 86 87 88 89 90 |
# File 'lib/little_ghost/sandbox/process_session.rb', line 81 def write(value) raise IOError, "process session is closed" if @closed @write_mutex.synchronize do @stdin_w.write(String(value)) @stdin_w.flush end rescue Errno::EPIPE raise IOError, "sandboxed process has exited" end |