Class: LittleGhost::Sandbox::ProcessSession

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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

Instance Attribute Details

#pidObject (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.

Returns:

  • (Boolean)

Raises:

  • (@resource_error)


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

#closeObject

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_writeObject

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

#terminateObject

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