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
-
.drain(reader, max_output) ⇒ Object
Collect the reader's output within a grace window.
-
.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).
-
.terminate(pid, kill) ⇒ Object
Kill the whole process group, then run any provider-specific teardown.
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 |