Skip to main content
Aotokitsuruya
Aotokitsuruya
Senior Software Developer
Published at

Kobako: Script Execution

This article is translated by AI, if have any corrections please let me know.
This article is part of Kobako series.

With standard output in place, whether a script runs correctly can finally be verified, which means we can start genuinely running Ruby inside Kobako’s sandbox environment.

Interface Design

At first I used #run to run a piece of Ruby script, but after a few times I started wondering whether that was really the right fit. If every time I want to run a script in Kobako I have to assemble a big chunk of code into it, does that really make sense?

For example, say I have a module I use a lot.

1module Formatter
2  module_function
3
4  def tag(name, *messages)
5    "[#{name}] #{messages.join(" ")}"
6  end
7end

Running it through #run would look like this.

 1sandbox.run(<<~SANDBOX)
 2module Formatter
 3  module_function
 4
 5  def tag(name, *messages)
 6    "[#{name}] #{messages.join(" ")}"
 7  end
 8end
 9
10puts Formatter.tag("info", "Hello World")
11SANDBOX

That means I have to “copy” the Formatter code every single time, which isn’t convenient. After weighing it up, I decided to introduce the idea of #preload.

 1sandbox.preload(<<~SANDBOX)
 2module Formatter
 3  module_function
 4
 5  def tag(name, *messages)
 6    "[#{name}] #{messages.join(" ")}"
 7  end
 8end
 9SANDBOX
10
11sandbox.run("puts Formatter.tag('info', 'Hello World')")

This way, there’s no need to keep putting Formatter inside #run on every call — preloading it once with #preload is enough.

The Snippet Mechanism

There is actually a perfectly good reason for designing it that way. Kobako only had #run to begin with because it was designed from a “disposable use” point of view.

1# Boilerplate
2sandbox = Sandbox.new
3# ...
4
5# mrb_state (independent)
6sandbox.run("true")
7
8# mrb_state (independent)
9sandbox.run("true")

A single mrb_state is a complete mruby VM (Virtual Machine), so two #run calls inherit nothing from each other, and that’s exactly why Formatter has to be copied in on every execution.

For a sandbox meant to be used by AI this is a relatively clean approach, since nothing pollutes anything else and there is no leftover state, so problems are less likely to happen. In exchange, though, it isn’t all that pleasant to use.

So #preload is actually very simple, and this is basically how it’s implemented.

 1class Sandbox
 2  attr_reader :snippets
 3
 4  def initialize
 5    # ...
 6    @snippets = []
 7  end
 8
 9  def preload(snippet)
10    @snippets << snippet
11  end
12
13  def run(code)
14    vm.run([*@snippets, code].join("\n\n"))
15  end
16end

As long as the code snippets to be preloaded are stored up front, they can be combined back together when it’s actually time to execute, which achieves much the same effect.

Run and Eval

Once #preload was in place, I adjusted things again into two methods, #eval and #run, because that opens up more different ways to use it, such as mimicking Rack’s design.

1sandbox.preload(<<~SANDBOX)
2class MyApp
3  def self.call(env)
4    puts "Hello, #{env[:name]}"
5  end
6end
7SANDBOX
8
9sandbox.run(:MyApp, name: "Aotoki")

In turn, the original #run became #eval, which sits closer to the meaning of executing a piece of code.

1sandbox.eval(<<~SANDBOX)
2  puts "Hello World"
3SANDBOX

But this clearly leaves a problem. How do we let the mrb_state inside WebAssembly know that it should call MyApp.call(...)?

This is where the WebAssembly memory exchange technique comes in, except the call travels in the opposite direction. Using WebAssembly’s Host Function capability, a call to either #run or #eval is routed to a single “start the mruby environment” method so that everything is handled in one place.

Since this needs Rust’s help, #run and #eval are in practice mapped to Ruby methods that share one boot method, which loads all the registered snippets in at once.

 1fn boot(snippets: &[Snippet]) -> Result<Mrb, Exc> {
 2    let mrb = Mrb::open();
 3    for s in snippets {
 4        mrb.load(&format!("(snippet:{})", s.name()), s.body());
 5        if let Some(e) = mrb.take_exception() { return Err(e); }
 6    }
 7    Ok(mrb)
 8}
 9
10fn outcome(mrb: &Mrb, value: Val) -> Outcome {
11    match mrb.take_exception() {
12        Some(e) => Outcome::Panic(e),
13        None => Outcome::Value(mrb.to_wire(value)),
14    }
15}
16
17fn eval(snippets: &[Snippet], source: &[u8]) -> Outcome {
18    let mrb = match boot(snippets) { Ok(m) => m, Err(e) => return Outcome::Panic(e) };
19
20    let value = mrb.load("(eval)", source);
21    outcome(&mrb, value)
22}
23
24fn run(snippets: &[Snippet], target: &str, args: Vec<Val>, kwargs: Option<Val>) -> Outcome {
25    let mrb = match boot(snippets) { Ok(m) => m, Err(e) => return Outcome::Panic(e) };
26
27    let Some(entrypoint) = mrb.top_level_const(target) else {
28        return Outcome::Panic(Exc::sandbox_error(format!("undefined entrypoint: {}", target)));
29    };
30    if !mrb.respond_to(&entrypoint, "call") {
31        return Outcome::Panic(Exc::sandbox_error(format!("entrypoint {} does not respond to :call", target)));
32    }
33
34    let mut argv = args;
35    argv.extend(kwargs);
36    let value = mrb.funcall(&entrypoint, "call", &argv);
37    outcome(&mrb, value)
38}

What sets #run apart from #eval is that it goes straight through mruby’s C API to check whether the object the user named has a callable (#call) method. If it does, the arguments handed in by the host (Host) are converted into a format mruby can understand and passed along.

So far there hasn’t been anything especially complex or difficult in Kobako’s design, as it’s all built on familiar mechanisms like method calls and storing variables. But it’s exactly this step-by-step evaluation of every use case that has gradually turned it into a sandbox that can actually be used.