mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-24 12:26:07 +08:00
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:
parent
464e30fa98
commit
88d194af80
5 changed files with 485 additions and 1 deletions
170
lib/live_book/evaluator.ex
Normal file
170
lib/live_book/evaluator.ex
Normal 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
|
||||
167
lib/live_book/evaluator/io_proxy.ex
Normal file
167
lib/live_book/evaluator/io_proxy.ex
Normal 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
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
36
test/live_book/evaluator/io_proxy_test.exs
Normal file
36
test/live_book/evaluator/io_proxy_test.exs
Normal 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
|
||||
111
test/live_book/evaluator_test.exs
Normal file
111
test/live_book/evaluator_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue