Module: Omakase::Executor::Subprocess

Defined in:
lib/omakase/executor.rb

Overview

Generated code runs in a child process so a timeout, a crash, or a runaway loop cannot take the parent with it. The child is a copy of this process — it can still reach ActiveRecord, ENV, and the disk. That is isolation of fate, not of capability. Untrusted input still belongs to :predict.

Ivars written in the child are marshalled back one at a time, so a generation's second tool call sees what the first one set. Methods the model defined on the object die with the child.

Class Method Summary collapse

Class Method Details

.call(agent, code, timeout: TIMEOUT) ⇒ Object



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/omakase/executor.rb', line 83

def call(agent, code, timeout: TIMEOUT)
  IO.pipe(binmode: true) do |reader, writer|
    pid = fork do
      reader.close
      # Own process group, so a timeout can kill grandchildren too.
      Process.setsid
      # Parent owns the deadline; Timeout here would race it.
      payload = pack(agent, Executor.call(agent, code, timeout: nil))
      writer.write([payload.bytesize].pack("N"), payload)
    ensure
      exit! 0
    end
    writer.close
    collect(reader, agent, pid, clock + timeout)
  end
end

.carry(result) ⇒ Object

Only an Answer can fail here — an observation is a String.



134
135
136
137
138
# File 'lib/omakase/executor.rb', line 134

def carry(result)
  return result if marshalable?(result)

  "cannot return #{result.value.class} across the process boundary"
end

.clock ⇒ Object



169
# File 'lib/omakase/executor.rb', line 169

def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)

.collect(reader, agent, pid, deadline) ⇒ Object



100
101
102
103
104
105
106
107
108
109
# File 'lib/omakase/executor.rb', line 100

def collect(reader, agent, pid, deadline)
  payload = read_packet(reader, deadline)
  stop(pid) if payload == :timeout
  status = reap(pid)
  case payload
  when :timeout then "execution timed out"
  when :eof then "child process #{fate(status)}"
  else unpack(agent, payload)
  end
end

.fate(status) ⇒ Object



171
172
173
174
175
176
# File 'lib/omakase/executor.rb', line 171

def fate(status)
  return "was killed" if status.nil? || status.signaled?
  return "ended without an answer" if status.success?

  "exited #{status.exitstatus}"
end

.marshalable?(value) ⇒ Boolean

Returns:

  • (Boolean)


117
118
119
120
121
122
# File 'lib/omakase/executor.rb', line 117

def marshalable?(value)
  Marshal.dump(value)
  true
rescue TypeError
  false
end

.note_dropped(result, dropped) ⇒ Object

A dropped ivar turns the answer into an observation: silent state loss would leave the next tool call reasoning about a value that is gone.



126
127
128
129
130
131
# File 'lib/omakase/executor.rb', line 126

def note_dropped(result, dropped)
  return result if dropped.empty?

  prior = result.is_a?(Answer) ? [result.printed, "finish #{result.value.inspect}"].reject(&:empty?).join("\n") : result
  Executor.observation([prior, "cannot keep #{dropped.join(", ")} across the process boundary"])
end

.pack(agent, result) ⇒ Object



111
112
113
114
115
# File 'lib/omakase/executor.rb', line 111

def pack(agent, result)
  kept, dropped = agent.marshal_dump.partition { |_, value| marshalable?(value) }
  result = note_dropped(result, dropped.map(&:first))
  Marshal.dump({result: carry(result), state: kept.to_h})
end

.read_exactly(io, n, deadline) ⇒ Object

select is exact for a pipe, so readpartial cannot block past the deadline.



157
158
159
160
161
162
163
164
165
166
167
# File 'lib/omakase/executor.rb', line 157

def read_exactly(io, n, deadline)
  buf = "".b
  while buf.bytesize < n
    return :timeout unless IO.select([io], nil, nil, [deadline - clock, 0].max)

    buf << io.readpartial(n - buf.bytesize)
  end
  buf
rescue EOFError
  :eof
end

.read_packet(io, deadline) ⇒ Object

Length-prefixed, so a leftover write-end cannot hang the parent.



149
150
151
152
153
154
# File 'lib/omakase/executor.rb', line 149

def read_packet(io, deadline)
  header = read_exactly(io, 4, deadline)
  return header if header.is_a?(Symbol)

  read_exactly(io, header.unpack1("N"), deadline)
end

.reap(pid) ⇒ Object

ECHILD: the host reaps children itself, with a CHLD trap.



186
187
188
189
190
# File 'lib/omakase/executor.rb', line 186

def reap(pid)
  Process.wait2(pid).last
rescue Errno::ECHILD
  nil
end

.stop(pid) ⇒ Object

The child led its own group unless the deadline beat it to setsid.



179
180
181
182
183
# File 'lib/omakase/executor.rb', line 179

def stop(pid)
  Process.kill("KILL", (Process.getpgid(pid) == pid) ? -pid : pid)
rescue Errno::ESRCH
  nil
end

.unpack(agent, payload) ⇒ Object



140
141
142
143
144
145
146
# File 'lib/omakase/executor.rb', line 140

def unpack(agent, payload)
  packet = Marshal.load(payload)
  agent.marshal_load(packet[:state])
  packet[:result]
rescue ArgumentError, TypeError => e
  "#{e.message}: a class defined in generated code does not survive the process boundary"
end