Skip to content

Commit

Permalink
Make remote execution cell work regardless of Elixir and OTP versions (
Browse files Browse the repository at this point in the history
…#363)

Co-authored-by: José Valim <[email protected]>
  • Loading branch information
jonatanklosko and josevalim authored Nov 8, 2023
1 parent 0c2a2cb commit f107cfa
Show file tree
Hide file tree
Showing 6 changed files with 215 additions and 127 deletions.
68 changes: 0 additions & 68 deletions lib/assets/remote_execution_cell/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -202,71 +202,3 @@ select.input {
.hidden-checkbox:hover {
cursor: pointer;
}

.icon-button {
background: none;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 100%;
color: var(--gray-500);
line-height: 1;
}

.icon-button:hover {
color: var(--gray-900);
}

.icon-button:focus {
outline: none;
}

.icon-button:disabled {
color: var(--gray-300);
cursor: default;
}

a.icon-button {
text-decoration: none;
}

a.icon-button img {
height: 1.3rem;
}

.ri {
font-size: 1.25rem;
vertical-align: middle;
line-height: 1;
}

.help-section {
padding: 16px;
font-size: 0.875rem;
line-height: 1.5em;
color: var(--gray-700);
border: solid 1px var(--gray-300);
background-color: rgba(248, 250, 252, 0.3);
}

.help-section > *:not(:first-child) {
margin-top: 20px;
}

.help-section h3 {
margin-bottom: 8px;
font-size: 1.125em;
}

.help-section a {
color: var(--gray-900);
font-weight: 500;
text-decoration: underline;
}

.help-section a:hover {
text-decoration: none;
}
13 changes: 0 additions & 13 deletions lib/assets/remote_execution_cell/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@ export function init(ctx, payload) {
ctx.importCSS(
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap"
);
ctx.importCSS(
"https://cdn.jsdelivr.net/npm/[email protected]/fonts/remixicon.min.css"
);
ctx.importCSS("main.css");

const BaseInput = {
Expand Down Expand Up @@ -214,12 +211,6 @@ export function init(ctx, payload) {
inputClass="input input--xs"
:inline
/>
<button type="button" @click="toggleHelpBox" class="icon-button grow">
<i class="ri ri-questionnaire-line" aria-hidden="true"></i>
</button>
</div>
<div class="help-section" v-if="showHelpBox">
<p>The Erlang/OTP version of both nodes need to match and the remote node must run Elixir v1.15.7 or later.</p>
</div>
</form>
</div>
Expand All @@ -228,7 +219,6 @@ export function init(ctx, payload) {
data() {
return {
fields: payload.fields,
showHelpBox: false,
};
},

Expand All @@ -248,9 +238,6 @@ export function init(ctx, payload) {
: this.fields["cookie"];
ctx.setSmartCellEditorIntellisenseNode(node, cookie);
},
toggleHelpBox() {
this.showHelpBox = !this.showHelpBox;
},
},

mounted() {
Expand Down
28 changes: 12 additions & 16 deletions lib/kino/remote_execution_cell.ex
Original file line number Diff line number Diff line change
Expand Up @@ -76,37 +76,33 @@ defmodule Kino.RemoteExecutionCell do
def to_source(%{"use_cookie_secret" => false, "cookie" => ""}), do: ""
def to_source(%{"use_cookie_secret" => true, "cookie_secret" => ""}), do: ""

def to_source(%{"code" => code} = attrs) do
code = Code.string_to_quoted(code)
to_source(attrs, code)
end

defp to_source(%{"assign_to" => var} = attrs, {:ok, code}) do
def to_source(%{"code" => code, "assign_to" => var} = attrs) do
var = if Kino.SmartCell.valid_variable_name?(var), do: var
call = build_call(code) |> build_var(var)
cookie = build_set_cookie(attrs)
node = build_node(attrs)

quote do
require Kino.RPC

node = unquote(node)
Node.set_cookie(node, unquote(cookie))
unquote(call)
end
|> Kino.SmartCell.quoted_to_string()
end

defp to_source(%{"code" => code}, {:error, _reason}) do
"# Invalid code for RPC, reproducing the error below\n" <>
Kino.SmartCell.quoted_to_string(
quote do
Code.string_to_quoted!(unquote(code))
end
)
end

defp build_call(code) do
quote do
:erpc.call(node, fn -> unquote(code) end)
Kino.RPC.eval_string(node, unquote(quoted_code(code)), file: __ENV__.file)
end
end

defp quoted_code(code) do
if String.contains?(code, "\n") do
{:<<>>, [delimiter: ~s["""]], [code <> "\n"]}
else
code
end
end

Expand Down
128 changes: 128 additions & 0 deletions lib/kino/rpc.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
defmodule Kino.RPC do
@moduledoc """
Functions for working with remote nodes.
"""

@relative_file Path.relative_to_cwd(__ENV__.file)

@doc """
Evaluates the contents given by `string` on the given `node`.
Returns the value returned from evaluation.
The code is analyzed for variable references, they are automatically
extracted from the caller binding and passed to the evaluation. This
means that the evaluated string actually has closure semantics.
The code is parsed and expanded on the remote node. Also, errors
and exists are captured and propagated to the caller.
See `Code.eval_string/3` for available `opts`.
"""
defmacro eval_string(node, string, opts \\ []) do
unless is_binary(string) do
raise ArgumentError,
"Kino.RPC.eval_string/3 expects a string literal as the second argument"
end

used_var_names = used_var_names(string, __CALLER__)

binding = for name <- used_var_names, do: {name, Macro.var(name, nil)}

quote do
Kino.RPC.__remote_eval_string__(
unquote(node),
unquote(string),
unquote(binding),
unquote(opts)
)
end
end

defp used_var_names(string, env) do
# TODO: only keep :emit_warnings once we require Elixir v1.16+
case Code.string_to_quoted(string, emit_warnings: false, warn_on_unnecessary_quotes: false) do
{:ok, ast} ->
# This is a simple heuristic, we traverse the unexpanded AST
# and look for any variable node. This means we may have false
# positives if there are macros, but in our use case this is
# acceptable. We may also have false negatives in very specific
# edge cases, such as calling `binding()`, but these are even
# more unlikely.

names = Map.new(Macro.Env.vars(env))

ast
|> Macro.prewalk(MapSet.new(), fn
{name, _, nil} = node, acc when is_map_key(names, name) ->
{node, MapSet.put(acc, name)}

node, acc ->
{node, acc}
end)
|> elem(1)

{:error, _} ->
[]
end
end

@doc false
def __remote_eval_string__(node, string, binding, opts) do
opts = Keyword.validate!(opts, [:file, :line])

# We do a nested evaluation to catch errors and capture diagnostics.
# Also, note that `eval_string` returns both result and binding,
# so in order to minimize the data sent between nodes, we bind the
# result and diagnostics to `output` and we rebind `input` to `nil`.

line = __ENV__.line + 4

eval_string =
"""
output =
Code.with_diagnostics([log: false], fn ->
{string, binding, opts} = input
try do
quoted = Code.string_to_quoted!(string, opts)
{value, _binding} = Code.eval_quoted(quoted, binding, opts)
{:ok, value}
catch
kind, error ->
{:error, kind, error, __STACKTRACE__}
end
end)
input = nil
"""

{nil, binding} =
:erpc.call(node, Code, :eval_string, [
eval_string,
[input: {string, binding, opts}],
[file: @relative_file, line: line]
])

{result, diagnostics} = binding[:output]

for diagnostic <- diagnostics do
Code.print_diagnostic(diagnostic)
end

case result do
{:ok, value} ->
value

{:error, :error, error, stacktrace} ->
error = Exception.normalize(:error, error, stacktrace)
reraise error, stacktrace

{:error, :throw, value, _stacktrace} ->
throw(value)

{:error, :exit, reason, _stacktrace} ->
exit(reason)
end
end
end
Loading

0 comments on commit f107cfa

Please sign in to comment.