class LittleGhost::Sandbox::ProcessSession
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.
Attributes
Operating-system process ID of the command process.
Public Class Methods
# 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 options = { in: @stdin_r, out: @stdout_w, err: @stderr_w, pgroup: true, unsetenv_others: !inherit_environment } options[:chdir] = chdir if chdir options[:rlimit_cpu] = [Integer(cpu_seconds), Integer(cpu_seconds)] if cpu_seconds options[: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), **options ) @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
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.
Public Instance Methods
Source
# File lib/little_ghost/sandbox/process_session.rb, line 74 def alive? raise @resource_error if @resource_error raw_alive? end
Whether the command process or its original process group is still alive. Raises when resource supervision failed.
Source
# 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
Terminates the process when needed and closes every owned stream. Calling close more than once is safe.
Source
# File lib/little_ghost/sandbox/process_session.rb, line 93 def close_write @stdin_w.close unless @stdin_w.closed? end
Closes the child’s standard input without ending the process.
Source
# 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
Reads currently available output, waiting for at most timeout seconds.
Source
# 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
Requests termination, forces it when needed, and returns the child’s Process::Status when available.
# 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
Waits for completion and returns the child’s Process::Status. When terminate is true, expiry stops the whole process group before raising.
Source
# 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
Writes value to the child’s standard input.