Module: Insika::Sandbox::Runner

Defined in:
lib/insika/sandbox/runner.rb

Overview

Spawns a command with a REAL hard-kill timeout, shared by every exec provider. Unlike the prototype's Open3.capture2e (which blocks uninterruptibly — a hung command holds the fiber until the OS returns), this bounds wall-clock: on expiry the process (and, via its own process group, any children) is force-killed and whatever partial output was captured is returned with timed_out: true.

Stdlib Timeout.timeout is deliberately NOT used (forbidden by the engine's fiber contract, see errors.rb); the deadline is enforced by Thread#join(timeout) — a single bounded blocking call — and the reader runs on its own thread so partial output survives the kill.

Constant Summary collapse

DRAIN_TIMEOUT =

Grace period for the output reader to drain after the process is killed.

2

Class Method Summary collapse

Class Method Details

.drain(reader, max_output) ⇒ Object

Collect the reader's output within a grace window. After a kill the write end closes and out.read returns promptly; the bound guards a reader that somehow does not (returns "" rather than blocking the fiber forever).

The output of an arbitrary command is arbitrary bytes: scrubbed to valid UTF-8 before clipping (the clip is then by character, never splitting one) because it becomes a tool result and gets JSON-serialized downstream.



73
74
75
76
77
# File 'lib/insika/sandbox/runner.rb', line 73

def drain(reader, max_output)
  return "" if reader.join(DRAIN_TIMEOUT).nil?

  Insika::Coercion.utf8(reader.value)[0, max_output]
end

.run(argv, chdir:, timeout:, max_output:, env: {}, kill: nil) ⇒ Object

argv: the command as an argv array (never a shell string — no re-interpretation by the host shell). chdir: working directory of the spawned process. kill: optional extra teardown (e.g. docker kill <name>); the process group is ALWAYS killed regardless.



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/insika/sandbox/runner.rb', line 39

def run(argv, chdir:, timeout:, max_output:, env: {}, kill: nil)
  # pgroup: true -> the child is a new group leader (pgid == pid), so a
  # single kill on the negated pid reaps the command AND anything it forked.
  Open3.popen2e(env, *argv, chdir: chdir, pgroup: true) do |stdin, out, wait_thr|
    stdin.close
    reader = Thread.new { out.read }

    if wait_thr.join(timeout).nil?
      terminate(wait_thr.pid, kill)
      Result.new(exit_status: nil, output: drain(reader, max_output), timed_out: true)
    else
      Result.new(exit_status: wait_thr.value.exitstatus,
                 output: drain(reader, max_output), timed_out: false)
    end
  end
end

.terminate(pid, kill) ⇒ Object

Kill the whole process group, then run any provider-specific teardown. ESRCH (already gone) / EPERM are benign races — the process is dying.



58
59
60
61
62
63
64
# File 'lib/insika/sandbox/runner.rb', line 58

def terminate(pid, kill)
  Process.kill("KILL", -pid)
rescue Errno::ESRCH, Errno::EPERM
  nil
ensure
  kill&.call
end