For the next few episodes I'll be alternating between Ruby and Python.
Previously we ran every piece of code separately. Now it's time to run them in shared context. There are many ways to do it, but the simplest is use a web server.
What does it mean to run a language server?
The simplest thing to do would be send JSON with some code in it and eval
it on the server. That would work really poorly - whenever we refreshed the page, we'd need to restart the server or old code would be all over the place. Not to mention all that eval
ing would get in a way of HTTP handling code.
To get full isolation we'd need to run a fresh process every time. This would be a lot of work, so we're going to get away with simpler solution of creating isolated Binding
object.
Capture output
We want to capture the output of the code and return it. For this we can override stdout
and stderr
in specific scope, then restore them to their original values once we're done.
def capture_output(new_stdout, new_stderr)
begin
$stdout, save_stdout = new_stdout, $stdout
$stderr, save_stderr = new_stderr, $stderr
yield
ensure
$stdout = save_stdout
$stderr = save_stderr
end
end
Eval code
Let's use that to run the code. First we create new fake output with StringIO.new
. We'll use it to capture both stdout
and stderr
, as annotating what was regular output and what was error can get compliacted. If you want to see how to do that, episode 17 has such code.
Then we run the code capturing its output. If it's an excepton, we capture that in response["error"]
.
What we don't capture is value returned by the code, as that can be anything, not necessarily something we can turn into a nice String
. In Ruby everything is an expression and that sometimes gets in a way of REPLs, for example foo.each{...}
returns foo
so you can chain it, but in a REPL we probably just want to see what the loop printed, not to see foo
after that. We could try to be clever, but for now let's just require explicit printing.
def eval_and_capture_output(code, context)
response = {}
output = StringIO.new
capture_output(output, output) do
begin
eval(code, context)
rescue Exception => e
response["error"] = "#{e.class}: #{e.message}"
end
end
response["output"] = output.string
response
end
New binding context
This is the moderate level of code isolation. We create a new empty Object
, and execute all code in that context. It will also define methods on that Object
.
Of course that's no sandbox, and if your code uses any global variables, Kernel
methods, require
s anything, and so one, it will escape with ease.
bindings
is a hash of Binding
objects, so whenever code tries to access bindings[x]
it didn't try before, it will create and save a new fresh Binding
there.
Old ones are never cleaned up, but you'll eventually shut down that server process anyway, so it's fine for now.
def new_binding
Object.new.instance_eval{ binding }
end
bindings = Hash.new{|h,k| h[k] = new_binding}
HTTP server
And finally a simple HTTP server. sinatra
doesn't do automatic JSON input and output conversion. It's really easy to istall a plugin to do so for us, but it's just three extra lines, so we might just do it manually.
require "sinatra"
post "/code" do
request.body.rewind
data = JSON.parse(request.body.read)
session_id = data["session_id"]
code = data["code"]
response = eval_and_capture_output(code, bindings[session_id])
response.to_json
end
Security
Just in case it's not clear, this is literally a server where anyone on your machine can send code, and it will execute it. Obviously this is extremely insecure thing to do.
By default sinatra
will only allow connections from your same computer, not even from other computers on the same network, but it really isn't hard to bypass that.
Result
Here's some interactions with the server. Notice different sessions are isolated.
In the next episode we'll connect this backend with our React frontend, so we can code Ruby in the browser.
As usual, all the code for the episode is here.
Top comments (0)