Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make remote execution cell work regardless of Elixir and OTP versions #363

Merged
merged 8 commits into from
Nov 8, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
126 changes: 126 additions & 0 deletions lib/kino/rpc.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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 an argument"
jonatanklosko marked this conversation as resolved.
Show resolved Hide resolved
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
case silent_string_to_quoted(string) do
jonatanklosko marked this conversation as resolved.
Show resolved Hide resolved
{:ok, ast} ->
names = for {name, nil} <- Macro.Env.vars(env), into: %{}, do: {name, nil}
jonatanklosko marked this conversation as resolved.
Show resolved Hide resolved

ast
|> Macro.prewalk(MapSet.new(), fn
{name, _, nil} = node, acc when is_atom(name) and is_map_key(names, name) ->
jonatanklosko marked this conversation as resolved.
Show resolved Hide resolved
{node, MapSet.put(acc, name)}

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

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

defp silent_string_to_quoted(string) do
Code.with_diagnostics([log: false], fn ->
Code.string_to_quoted(string)
end)
|> elem(0)
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