DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

Electron Adventures: Episode 55: Ruby Language Server

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 evaling 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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, requires 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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Episode 55 Screenshot

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.

Discussion (0)