Introduce the evaluator process (#5)

* Add code evaluation server

* Capture evaluator standard output and send to the caller

* Return full error info from evaluator

* Add support for deleting evaluation from hitory

* Fix a typo

* Start IOProxy once per Evalutor

* Apply review suggestions
This commit is contained in:
Jonatan Kłosko 2021-01-11 12:05:05 +01:00 committed by GitHub
parent 464e30fa98
commit 88d194af80
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 485 additions and 1 deletions

170
lib/live_book/evaluator.ex Normal file
View file

@ -0,0 +1,170 @@
defmodule LiveBook.Evaluator do
@moduledoc false
# A process responsible for evaluating notebook code.
#
# The process receives evaluation request and synchronously
# evaluates the given code within itself (rather than spawning a separate process).
# It stores the resulting binding and env as part of the state.
#
# It's important to store the binding in the same process
# where the evaluation happens, as otherwise we would have to
# send them between processes, effectively copying potentially large data.
use GenServer
alias LiveBook.Evaluator
@type t :: GenServer.server()
@type state :: %{
io_proxy: pid(),
contexts: %{ref() => context()}
}
@typedoc """
An evaluation context.
"""
@type context :: %{binding: Code.binding(), env: Macro.Env.t()}
@typedoc """
A term used to identify evaluation.
"""
@type ref :: term()
@typedoc """
Either {:ok, result} for successfull evaluation
or {:error, kind, error, stacktrace} for a failed one.
"""
@type evaluation_response ::
{:ok, any()} | {:error, Exception.kind(), any(), Exception.stacktrace()}
## API
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts)
end
@doc """
Synchronously parses and evaluates the given code.
Any exceptions are captured, in which case this method returns an error.
The evaluator stores the resulting binding and environment under `ref`.
Any subsequent calls may specify `prev_ref` pointing to a previous evaluation,
in which case the corresponding binding and environment are used during evaluation.
"""
@spec evaluate_code(t(), String.t(), ref(), ref()) :: evaluation_response()
def evaluate_code(evaluator, code, ref, prev_ref \\ :initial) when ref != :initial do
response = GenServer.call(evaluator, {:evaluate_code, code, ref, prev_ref}, :infinity)
if response == :invalid_prev_ref do
raise ArgumentError, message: "invalid reference to previous evaluation: #{prev_ref}"
end
response
end
@doc """
Removes the evaluation identified by `ref` from history,
so that further evaluations cannot use it.
"""
@spec forget_evaluation(t(), ref()) :: :ok
def forget_evaluation(evaluator, ref) do
GenServer.cast(evaluator, {:forget_evaluation, ref})
end
## Callbacks
@impl true
def init(_opts) do
{:ok, io_proxy} = Evaluator.IOProxy.start_link()
# Use the dedicated IO device as the group leader,
# so that it handles all :stdio operations.
Process.group_leader(self(), io_proxy)
{:ok, initial_state(io_proxy)}
end
defp initial_state(io_proxy) do
%{
io_proxy: io_proxy,
contexts: %{initial: initial_context()}
}
end
defp initial_context() do
env = :elixir.env_for_eval([])
%{binding: [], env: env}
end
@impl true
def handle_call({:evaluate_code, code, ref, prev_ref}, {from, _}, state) do
case Map.fetch(state.contexts, prev_ref) do
:error ->
{:reply, :invalid_prev_ref, state}
{:ok, context} ->
Evaluator.IOProxy.configure(state.io_proxy, from, ref)
case eval(code, context.binding, context.env) do
{:ok, result, binding, env} ->
result_context = %{binding: binding, env: env}
new_contexts = Map.put(state.contexts, ref, result_context)
new_state = %{state | contexts: new_contexts}
{:reply, {:ok, result}, new_state}
{:error, kind, error, stacktrace} ->
{:reply, {:error, kind, error, stacktrace}, state}
end
end
end
@impl true
def handle_cast({:forget_evaluation, ref}, state) do
new_state = %{state | contexts: Map.delete(state.contexts, ref)}
{:noreply, new_state}
end
defp eval(code, binding, env) do
try do
quoted = Code.string_to_quoted!(code)
{result, binding, env} = :elixir.eval_quoted(quoted, binding, env)
{:ok, result, binding, env}
catch
kind, error ->
{kind, error, stacktrace} = prepare_error(kind, error, __STACKTRACE__)
{:error, kind, error, stacktrace}
end
end
defp prepare_error(kind, error, stacktrace) do
{error, stacktrace} = Exception.blame(kind, error, stacktrace)
stacktrace = prune_stacktrace(stacktrace)
{kind, error, stacktrace}
end
# Adapted from https://github.com/elixir-lang/elixir/blob/1c1654c88adfdbef38ff07fc30f6fbd34a542c07/lib/iex/lib/iex/evaluator.ex#L355-L372
@elixir_internals [:elixir, :elixir_expand, :elixir_compiler, :elixir_module] ++
[:elixir_clauses, :elixir_lexical, :elixir_def, :elixir_map] ++
[:elixir_erl, :elixir_erl_clauses, :elixir_erl_pass]
defp prune_stacktrace(stacktrace) do
# The order in which each drop_while is listed is important.
# For example, the user may call Code.eval_string/2 in their code
# and if there is an error we should not remove erl_eval
# and eval_bits information from the user stacktrace.
stacktrace
|> Enum.reverse()
|> Enum.drop_while(&(elem(&1, 0) == :proc_lib))
|> Enum.drop_while(&(elem(&1, 0) == :gen_server))
|> Enum.drop_while(&(elem(&1, 0) == __MODULE__))
|> Enum.drop_while(&(elem(&1, 0) == :elixir))
|> Enum.drop_while(&(elem(&1, 0) in [:erl_eval, :eval_bits]))
|> Enum.reverse()
|> Enum.reject(&(elem(&1, 0) in @elixir_internals))
end
end

View file

@ -0,0 +1,167 @@
defmodule LiveBook.Evaluator.IOProxy do
@moduledoc false
# An IO device process used by `Evaluator` as its `:stdio`.
#
# The process implements [The Erlang I/O Protocol](https://erlang.org/doc/apps/stdlib/io_protocol.html)
# and can be thought of as a *virtual* IO device.
#
# Upon receiving an IO requests, the process sends a message
# the `target` process specified during initialization.
# Currently only output requests are supported.
#
# The implementation is based on the build-in `StringIO`,
# so check it out for more reference.
use GenServer
alias LiveBook.Evaluator
## API
@doc """
Starts the IO device process.
Make sure to use `configure/3` to actually proxy the requests.
"""
@spec start_link() :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts)
end
@doc """
Sets IO proxy destination and the reference to be attached to all messages.
For all supported requests a message is sent to `target`,
so this device serves as a proxy. The given evaluation
reference (`ref`) is also sent in all messages.
The possible messages are:
* `{:evaluator_stdout, ref, string}` - for output requests,
where `ref` is the given evaluation reference and `string` is the output.
"""
@spec configure(pid(), pid(), Evaluator.ref()) :: :ok
def configure(pid, target, ref) do
GenServer.cast(pid, {:configure, target, ref})
end
## Callbacks
@impl true
def init(_opts) do
{:ok, %{encoding: :unicode, target: nil, ref: nil}}
end
@impl true
def handle_cast({:configure, target, ref}, state) do
{:noreply, %{state | target: target, ref: ref}}
end
@impl true
def handle_info({:io_request, from, reply_as, req}, state) do
{reply, state} = io_request(req, state)
io_reply(from, reply_as, reply)
{:noreply, state}
end
defp io_request({:put_chars, chars} = req, state) do
put_chars(:latin1, chars, req, state)
end
defp io_request({:put_chars, mod, fun, args} = req, state) do
put_chars(:latin1, apply(mod, fun, args), req, state)
end
defp io_request({:put_chars, encoding, chars} = req, state) do
put_chars(encoding, chars, req, state)
end
defp io_request({:put_chars, encoding, mod, fun, args} = req, state) do
put_chars(encoding, apply(mod, fun, args), req, state)
end
defp io_request({:get_chars, _prompt, count}, state) when count >= 0 do
{{:error, :enotsup}, state}
end
defp io_request({:get_chars, _encoding, _prompt, count}, state) when count >= 0 do
{{:error, :enotsup}, state}
end
defp io_request({:get_line, _prompt}, state) do
{{:error, :enotsup}, state}
end
defp io_request({:get_line, _encoding, _prompt}, state) do
{{:error, :enotsup}, state}
end
defp io_request({:get_until, _prompt, _mod, _fun, _args}, state) do
{{:error, :enotsup}, state}
end
defp io_request({:get_until, _encoding, _prompt, _mod, _fun, _args}, state) do
{{:error, :enotsup}, state}
end
defp io_request({:get_password, _encoding}, state) do
{{:error, :enotsup}, state}
end
defp io_request({:setopts, [encoding: encoding]}, state) when encoding in [:latin1, :unicode] do
{:ok, %{state | encoding: encoding}}
end
defp io_request({:setopts, _opts}, state) do
{{:error, :enotsup}, state}
end
defp io_request(:getopts, state) do
{[binary: true, encoding: state.encoding], state}
end
defp io_request({:get_geometry, :columns}, state) do
{{:error, :enotsup}, state}
end
defp io_request({:get_geometry, :rows}, state) do
{{:error, :enotsup}, state}
end
defp io_request({:requests, reqs}, state) do
io_requests(reqs, {:ok, state})
end
defp io_request(_, state) do
{{:error, :request}, state}
end
defp io_requests([req | rest], {:ok, state}) do
io_requests(rest, io_request(req, state))
end
defp io_requests(_, result) do
result
end
defp put_chars(encoding, chars, req, state) do
case :unicode.characters_to_binary(chars, encoding, state.encoding) do
string when is_binary(string) ->
if state.target do
send(state.target, {:evaluator_stdout, state.ref, string})
end
{:ok, state}
{_, _, _} ->
{{:error, req}, state}
end
rescue
ArgumentError -> {{:error, req}, state}
end
defp io_reply(from, reply_as, reply) do
send(from, {:io_reply, reply_as, reply})
end
end

View file

@ -4,7 +4,7 @@ defmodule LiveBook.Notebook.Cell do
A cell is the smallest unit of work in a notebook.
It primarly consists of text content that the user can edit
and may potentially produce some output (e.g. during code execution).
and may potentially produce some output (e.g. during code evaluation).
"""
defstruct [:id, :type, :source, :outputs, :metadata]

View file

@ -0,0 +1,36 @@
defmodule LiveBook.Evaluator.IOProxyTest do
use ExUnit.Case, async: true
alias LiveBook.Evaluator.IOProxy
setup do
{:ok, io} = IOProxy.start_link()
IOProxy.configure(io, self(), :ref)
%{io: io}
end
# Test the basic ways users interact with :stdio
test "IO.puts", %{io: io} do
IO.puts(io, "hey")
assert_received {:evaluator_stdout, :ref, "hey\n"}
end
test "IO.write", %{io: io} do
IO.write(io, "hey")
assert_received {:evaluator_stdout, :ref, "hey"}
end
test "IO.inspect", %{io: io} do
IO.inspect(io, %{}, [])
assert_received {:evaluator_stdout, :ref, "%{}\n"}
end
test "IO.read", %{io: io} do
assert IO.read(io, :all) == {:error, :enotsup}
end
test "IO.gets", %{io: io} do
assert IO.gets(io, "> ") == {:error, :enotsup}
end
end

View file

@ -0,0 +1,111 @@
defmodule LiveBook.EvaluatorTest do
use ExUnit.Case, async: true
alias LiveBook.Evaluator
setup do
{:ok, evaluator} = Evaluator.start_link()
%{evaluator: evaluator}
end
describe "evaluate_code/4" do
test "given a valid code returns evaluation result", %{evaluator: evaluator} do
code = """
x = 1
y = 2
x + y
"""
result = Evaluator.evaluate_code(evaluator, code, 1)
assert result == {:ok, 3}
end
test "given no prev_ref does not see previous evaluation context", %{evaluator: evaluator} do
Evaluator.evaluate_code(evaluator, "x = 1", :code_1)
result = Evaluator.evaluate_code(evaluator, "x", :code_2)
assert {:error, _kind, %CompileError{description: "undefined function x/0"}, _stacktrace} =
result
end
test "given prev_ref sees previous evaluation context", %{evaluator: evaluator} do
Evaluator.evaluate_code(evaluator, "x = 1", :code_1)
result = Evaluator.evaluate_code(evaluator, "x", :code_2, :code_1)
assert result == {:ok, 1}
end
test "given invalid prev_ref raises an error", %{evaluator: evaluator} do
assert_raise ArgumentError, fn ->
Evaluator.evaluate_code(evaluator, ":ok", :code_1, :code_nonexistent)
end
end
test "captures standard output and sends it to the caller", %{evaluator: evaluator} do
Evaluator.evaluate_code(evaluator, ~s{IO.puts("hey")}, :code_1)
assert_received {:evaluator_stdout, :code_1, "hey\n"}
end
test "using standard input results in an immediate error", %{evaluator: evaluator} do
result = Evaluator.evaluate_code(evaluator, ~s{IO.gets("> ")}, :code_1)
assert result == {:ok, {:error, :enotsup}}
end
test "returns error along with its kind and stacktrace", %{evaluator: evaluator} do
code = """
List.first(%{})
"""
result = Evaluator.evaluate_code(evaluator, code, :code_1)
assert {:error, :error, %FunctionClauseError{}, [{List, :first, 1, _location}]} = result
end
test "in case of an error returns only the relevant part of stacktrace", %{
evaluator: evaluator
} do
code = """
defmodule Math do
def bad_math do
result = 1 / 0
{:ok, result}
end
end
defmodule Cat do
def meow do
Math.bad_math()
:ok
end
end
Cat.meow()
"""
result = Evaluator.evaluate_code(evaluator, code, :code_1)
expected_stacktrace = [
{Math, :bad_math, 0, [file: 'nofile', line: 3]},
{Cat, :meow, 0, [file: 'nofile', line: 10]}
]
assert {:error, _kind, _error, ^expected_stacktrace} = result
end
end
describe "forget_evaluation/2" do
test "invalidates the given reference", %{evaluator: evaluator} do
Evaluator.evaluate_code(evaluator, "x = 1", :code_1)
Evaluator.forget_evaluation(evaluator, :code_1)
assert_raise ArgumentError, fn ->
Evaluator.evaluate_code(evaluator, ":ok", :code_2, :code_1)
end
end
end
end