mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-12-12 06:36:42 +08:00
Add support for controls output type (#710)
* Add support for controls output type * Split controls into individual widgets * Adjust ids * Improve widget and controls garbage collection * Allow arbitrary functions as object release hook * Add type to button and input events * Add keyboard status event * Change release hooks into monitor messages * Rename pointer to reference and return an error on bad monitor
This commit is contained in:
parent
236ea4dd96
commit
264d6c3ff2
21 changed files with 579 additions and 152 deletions
|
|
@ -24,6 +24,7 @@ import MarkdownRenderer from "./markdown_renderer";
|
||||||
import Highlight from "./highlight";
|
import Highlight from "./highlight";
|
||||||
import DragAndDrop from "./drag_and_drop";
|
import DragAndDrop from "./drag_and_drop";
|
||||||
import PasswordToggle from "./password_toggle";
|
import PasswordToggle from "./password_toggle";
|
||||||
|
import KeyboardControl from "./keyboard_control";
|
||||||
import morphdomCallbacks from "./morphdom_callbacks";
|
import morphdomCallbacks from "./morphdom_callbacks";
|
||||||
import { loadUserData } from "./lib/user";
|
import { loadUserData } from "./lib/user";
|
||||||
|
|
||||||
|
|
@ -41,6 +42,7 @@ const hooks = {
|
||||||
Highlight,
|
Highlight,
|
||||||
DragAndDrop,
|
DragAndDrop,
|
||||||
PasswordToggle,
|
PasswordToggle,
|
||||||
|
KeyboardControl,
|
||||||
};
|
};
|
||||||
|
|
||||||
const csrfToken = document
|
const csrfToken = document
|
||||||
|
|
|
||||||
91
assets/js/keyboard_control/index.js
Normal file
91
assets/js/keyboard_control/index.js
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { getAttributeOrThrow, parseBoolean } from "../lib/attribute";
|
||||||
|
import { cancelEvent } from "../lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hook for ControlComponent to handle user keyboard interactions.
|
||||||
|
*
|
||||||
|
* Configuration:
|
||||||
|
*
|
||||||
|
* * `data-keydown-enabled` - whether keydown events should be intercepted
|
||||||
|
*
|
||||||
|
* * `data-keyup-enabled` - whether keyup events should be intercepted
|
||||||
|
*
|
||||||
|
* * `data-target` - the target to send live events to
|
||||||
|
*/
|
||||||
|
const KeyboardControl = {
|
||||||
|
mounted() {
|
||||||
|
this.props = getProps(this);
|
||||||
|
|
||||||
|
this.handleDocumentKeyDown = (event) => {
|
||||||
|
handleDocumentKeyDown(this, event);
|
||||||
|
};
|
||||||
|
|
||||||
|
// We intentionally register on window rather than document,
|
||||||
|
// to intercept clicks as early on as possible, even before
|
||||||
|
// the session shortcuts
|
||||||
|
window.addEventListener("keydown", this.handleDocumentKeyDown, true);
|
||||||
|
|
||||||
|
this.handleDocumentKeyUp = (event) => {
|
||||||
|
handleDocumentKeyUp(this, event);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keyup", this.handleDocumentKeyUp, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
updated() {
|
||||||
|
this.props = getProps(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
window.removeEventListener("keydown", this.handleDocumentKeyDown, true);
|
||||||
|
window.removeEventListener("keyup", this.handleDocumentKeyUp, true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getProps(hook) {
|
||||||
|
return {
|
||||||
|
isKeydownEnabled: getAttributeOrThrow(
|
||||||
|
hook.el,
|
||||||
|
"data-keydown-enabled",
|
||||||
|
parseBoolean
|
||||||
|
),
|
||||||
|
isKeyupEnabled: getAttributeOrThrow(
|
||||||
|
hook.el,
|
||||||
|
"data-keyup-enabled",
|
||||||
|
parseBoolean
|
||||||
|
),
|
||||||
|
target: getAttributeOrThrow(hook.el, "data-target"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentKeyDown(hook, event) {
|
||||||
|
if (keyboardEnabled(hook)) {
|
||||||
|
cancelEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hook.props.isKeydownEnabled) {
|
||||||
|
if (event.repeat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = event.key;
|
||||||
|
hook.pushEventTo(hook.props.target, "keydown", { key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentKeyUp(hook, event) {
|
||||||
|
if (keyboardEnabled(hook)) {
|
||||||
|
cancelEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hook.props.isKeyupEnabled) {
|
||||||
|
const key = event.key;
|
||||||
|
hook.pushEventTo(hook.props.target, "keyup", { key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyboardEnabled(hook) {
|
||||||
|
return hook.props.isKeydownEnabled || hook.props.isKeyupEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KeyboardControl;
|
||||||
|
|
@ -127,3 +127,10 @@ export function findChildOrThrow(element, selector) {
|
||||||
|
|
||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cancelEvent(event) {
|
||||||
|
// Cancel any default browser behavior.
|
||||||
|
event.preventDefault();
|
||||||
|
// Stop event propagation (e.g. so it doesn't reach the editor).
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
selectElementContent,
|
selectElementContent,
|
||||||
smoothlyScrollToElement,
|
smoothlyScrollToElement,
|
||||||
setFavicon,
|
setFavicon,
|
||||||
|
cancelEvent,
|
||||||
} from "../lib/utils";
|
} from "../lib/utils";
|
||||||
import { getAttributeOrDefault } from "../lib/attribute";
|
import { getAttributeOrDefault } from "../lib/attribute";
|
||||||
import KeyBuffer from "./key_buffer";
|
import KeyBuffer from "./key_buffer";
|
||||||
|
|
@ -572,7 +573,7 @@ function initializeFocus(hook) {
|
||||||
const element = document.getElementById(htmlId);
|
const element = document.getElementById(htmlId);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
const focusableEl = elementelement.closest("[data-focusable-id]");
|
const focusableEl = element.closest("[data-focusable-id]");
|
||||||
|
|
||||||
if (focusableEl) {
|
if (focusableEl) {
|
||||||
setFocusedEl(hook, focusableEl.dataset.focusableId);
|
setFocusedEl(hook, focusableEl.dataset.focusableId);
|
||||||
|
|
@ -1067,11 +1068,4 @@ function getRuntimeInfoToggle() {
|
||||||
return document.querySelector(`[data-element="runtime-info-toggle"]`);
|
return document.querySelector(`[data-element="runtime-info-toggle"]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEvent(event) {
|
|
||||||
// Cancel any default browser behavior.
|
|
||||||
event.preventDefault();
|
|
||||||
// Stop event propagation (e.g. so it doesn't reach the editor).
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Session;
|
export default Session;
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,12 @@ defmodule Livebook.Evaluator do
|
||||||
@type t :: %{pid: pid(), ref: reference()}
|
@type t :: %{pid: pid(), ref: reference()}
|
||||||
|
|
||||||
@type state :: %{
|
@type state :: %{
|
||||||
|
ref: reference(),
|
||||||
formatter: module(),
|
formatter: module(),
|
||||||
io_proxy: pid(),
|
io_proxy: pid(),
|
||||||
|
object_tracker: pid(),
|
||||||
contexts: %{ref() => context()},
|
contexts: %{ref() => context()},
|
||||||
initial_context: context(),
|
initial_context: context()
|
||||||
# We track the widgets rendered by every evaluation,
|
|
||||||
# so that we can kill those no longer needed
|
|
||||||
widget_pids: %{ref() => MapSet.t(pid())},
|
|
||||||
widget_counts: %{pid() => non_neg_integer()}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@typedoc """
|
@typedoc """
|
||||||
|
|
@ -57,6 +55,8 @@ defmodule Livebook.Evaluator do
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
|
|
||||||
|
* `object_tracker` - a PID of `Livebook.Evaluator.ObjectTracker`, required
|
||||||
|
|
||||||
* `formatter` - a module implementing the `Livebook.Evaluator.Formatter` behaviour,
|
* `formatter` - a module implementing the `Livebook.Evaluator.Formatter` behaviour,
|
||||||
used for transforming evaluation response before it's sent to the client
|
used for transforming evaluation response before it's sent to the client
|
||||||
"""
|
"""
|
||||||
|
|
@ -171,16 +171,18 @@ defmodule Livebook.Evaluator do
|
||||||
end
|
end
|
||||||
|
|
||||||
def init(opts) do
|
def init(opts) do
|
||||||
|
object_tracker = Keyword.fetch!(opts, :object_tracker)
|
||||||
formatter = Keyword.get(opts, :formatter, Evaluator.IdentityFormatter)
|
formatter = Keyword.get(opts, :formatter, Evaluator.IdentityFormatter)
|
||||||
|
|
||||||
{:ok, io_proxy} = Evaluator.IOProxy.start_link()
|
{:ok, io_proxy} = Evaluator.IOProxy.start_link(self(), object_tracker)
|
||||||
|
|
||||||
# Use the dedicated IO device as the group leader,
|
# Use the dedicated IO device as the group leader, so that
|
||||||
# so that it handles all :stdio operations.
|
# intercepts all :stdio requests and also handles Livebook
|
||||||
|
# specific ones
|
||||||
Process.group_leader(self(), io_proxy)
|
Process.group_leader(self(), io_proxy)
|
||||||
|
|
||||||
evaluator_ref = make_ref()
|
evaluator_ref = make_ref()
|
||||||
state = initial_state(evaluator_ref, formatter, io_proxy)
|
state = initial_state(evaluator_ref, formatter, io_proxy, object_tracker)
|
||||||
evaluator = %{pid: self(), ref: evaluator_ref}
|
evaluator = %{pid: self(), ref: evaluator_ref}
|
||||||
|
|
||||||
:proc_lib.init_ack(evaluator)
|
:proc_lib.init_ack(evaluator)
|
||||||
|
|
@ -188,15 +190,14 @@ defmodule Livebook.Evaluator do
|
||||||
loop(state)
|
loop(state)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp initial_state(evaluator_ref, formatter, io_proxy) do
|
defp initial_state(evaluator_ref, formatter, io_proxy, object_tracker) do
|
||||||
%{
|
%{
|
||||||
evaluator_ref: evaluator_ref,
|
evaluator_ref: evaluator_ref,
|
||||||
formatter: formatter,
|
formatter: formatter,
|
||||||
io_proxy: io_proxy,
|
io_proxy: io_proxy,
|
||||||
|
object_tracker: object_tracker,
|
||||||
contexts: %{},
|
contexts: %{},
|
||||||
initial_context: initial_context(),
|
initial_context: initial_context()
|
||||||
widget_pids: %{},
|
|
||||||
widget_counts: %{}
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -221,6 +222,8 @@ defmodule Livebook.Evaluator do
|
||||||
defp handle_cast({:evaluate_code, send_to, code, ref, prev_ref, opts}, state) do
|
defp handle_cast({:evaluate_code, send_to, code, ref, prev_ref, opts}, state) do
|
||||||
Evaluator.IOProxy.configure(state.io_proxy, send_to, ref)
|
Evaluator.IOProxy.configure(state.io_proxy, send_to, ref)
|
||||||
|
|
||||||
|
Evaluator.ObjectTracker.remove_reference(state.object_tracker, {self(), ref})
|
||||||
|
|
||||||
context = get_context(state, prev_ref)
|
context = get_context(state, prev_ref)
|
||||||
file = Keyword.get(opts, :file, "nofile")
|
file = Keyword.get(opts, :file, "nofile")
|
||||||
context = put_in(context.env.file, file)
|
context = put_in(context.env.file, file)
|
||||||
|
|
@ -249,18 +252,12 @@ defmodule Livebook.Evaluator do
|
||||||
metadata = %{evaluation_time_ms: evaluation_time_ms}
|
metadata = %{evaluation_time_ms: evaluation_time_ms}
|
||||||
send(send_to, {:evaluation_response, ref, output, metadata})
|
send(send_to, {:evaluation_response, ref, output, metadata})
|
||||||
|
|
||||||
widget_pids = Evaluator.IOProxy.flush_widgets(state.io_proxy)
|
|
||||||
state = track_evaluation_widgets(state, ref, widget_pids, output)
|
|
||||||
|
|
||||||
{:noreply, state}
|
{:noreply, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_cast({:forget_evaluation, ref}, state) do
|
defp handle_cast({:forget_evaluation, ref}, state) do
|
||||||
state =
|
state = Map.update!(state, :contexts, &Map.delete(&1, ref))
|
||||||
state
|
Evaluator.ObjectTracker.remove_reference(state.object_tracker, {self(), ref})
|
||||||
|> Map.update!(:contexts, &Map.delete(&1, ref))
|
|
||||||
|> garbage_collect_widgets(ref, [])
|
|
||||||
|
|
||||||
{:noreply, state}
|
{:noreply, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -372,56 +369,6 @@ defmodule Livebook.Evaluator do
|
||||||
defp internal_dictionary_key?("$" <> _), do: true
|
defp internal_dictionary_key?("$" <> _), do: true
|
||||||
defp internal_dictionary_key?(_), do: false
|
defp internal_dictionary_key?(_), do: false
|
||||||
|
|
||||||
# Widgets
|
|
||||||
|
|
||||||
defp track_evaluation_widgets(state, ref, widget_pids, output) do
|
|
||||||
widget_pids =
|
|
||||||
case widget_pid_from_output(output) do
|
|
||||||
{:ok, pid} -> MapSet.put(widget_pids, pid)
|
|
||||||
:error -> widget_pids
|
|
||||||
end
|
|
||||||
|
|
||||||
garbage_collect_widgets(state, ref, widget_pids)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp garbage_collect_widgets(state, ref, widget_pids) do
|
|
||||||
prev_widget_pids = state.widget_pids[ref] || []
|
|
||||||
|
|
||||||
state = put_in(state.widget_pids[ref], widget_pids)
|
|
||||||
|
|
||||||
update_in(state.widget_counts, fn counts ->
|
|
||||||
counts =
|
|
||||||
Enum.reduce(prev_widget_pids, counts, fn pid, counts ->
|
|
||||||
Map.update!(counts, pid, &(&1 - 1))
|
|
||||||
end)
|
|
||||||
|
|
||||||
counts =
|
|
||||||
Enum.reduce(widget_pids, counts, fn pid, counts ->
|
|
||||||
Map.update(counts, pid, 1, &(&1 + 1))
|
|
||||||
end)
|
|
||||||
|
|
||||||
{to_remove, to_keep} = Enum.split_with(counts, fn {_pid, count} -> count == 0 end)
|
|
||||||
|
|
||||||
for {pid, 0} <- to_remove do
|
|
||||||
Process.exit(pid, :shutdown)
|
|
||||||
end
|
|
||||||
|
|
||||||
Map.new(to_keep)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Checks the given output value for widget pid to track.
|
|
||||||
"""
|
|
||||||
@spec widget_pid_from_output(term()) :: {:ok, pid()} | :error
|
|
||||||
def widget_pid_from_output(output)
|
|
||||||
|
|
||||||
def widget_pid_from_output({_type, pid}) when is_pid(pid) do
|
|
||||||
{:ok, pid}
|
|
||||||
end
|
|
||||||
|
|
||||||
def widget_pid_from_output(_output), do: :error
|
|
||||||
|
|
||||||
defp get_execution_time_delta(started_at) do
|
defp get_execution_time_delta(started_at) do
|
||||||
System.monotonic_time()
|
System.monotonic_time()
|
||||||
|> Kernel.-(started_at)
|
|> Kernel.-(started_at)
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,9 @@ defmodule Livebook.Evaluator.IOProxy do
|
||||||
|
|
||||||
Make sure to use `configure/3` to actually proxy the requests.
|
Make sure to use `configure/3` to actually proxy the requests.
|
||||||
"""
|
"""
|
||||||
@spec start_link() :: GenServer.on_start()
|
@spec start_link(pid(), pid()) :: GenServer.on_start()
|
||||||
def start_link(opts \\ []) do
|
def start_link(evaluator, object_tracker) do
|
||||||
GenServer.start_link(__MODULE__, opts)
|
GenServer.start_link(__MODULE__, evaluator: evaluator, object_tracker: object_tracker)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -80,7 +80,10 @@ defmodule Livebook.Evaluator.IOProxy do
|
||||||
## Callbacks
|
## Callbacks
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def init(_opts) do
|
def init(opts) do
|
||||||
|
evaluator = Keyword.fetch!(opts, :evaluator)
|
||||||
|
object_tracker = Keyword.fetch!(opts, :object_tracker)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
%{
|
%{
|
||||||
encoding: :unicode,
|
encoding: :unicode,
|
||||||
|
|
@ -88,8 +91,9 @@ defmodule Livebook.Evaluator.IOProxy do
|
||||||
ref: nil,
|
ref: nil,
|
||||||
buffer: [],
|
buffer: [],
|
||||||
input_cache: %{},
|
input_cache: %{},
|
||||||
widget_pids: MapSet.new(),
|
token_count: 0,
|
||||||
token_count: 0
|
evaluator: evaluator,
|
||||||
|
object_tracker: object_tracker
|
||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -107,10 +111,6 @@ defmodule Livebook.Evaluator.IOProxy do
|
||||||
{:reply, :ok, flush_buffer(state)}
|
{:reply, :ok, flush_buffer(state)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_call(:flush_widgets, _from, state) do
|
|
||||||
{:reply, state.widget_pids, %{state | widget_pids: MapSet.new()}}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:io_request, from, reply_as, req}, state) do
|
def handle_info({:io_request, from, reply_as, req}, state) do
|
||||||
{reply, state} = io_request(req, state)
|
{reply, state} = io_request(req, state)
|
||||||
|
|
@ -198,13 +198,6 @@ defmodule Livebook.Evaluator.IOProxy do
|
||||||
defp io_request({:livebook_put_output, output}, state) do
|
defp io_request({:livebook_put_output, output}, state) do
|
||||||
state = flush_buffer(state)
|
state = flush_buffer(state)
|
||||||
send(state.target, {:evaluation_output, state.ref, output})
|
send(state.target, {:evaluation_output, state.ref, output})
|
||||||
|
|
||||||
state =
|
|
||||||
case Evaluator.widget_pid_from_output(output) do
|
|
||||||
{:ok, pid} -> update_in(state.widget_pids, &MapSet.put(&1, pid))
|
|
||||||
:error -> state
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -224,6 +217,27 @@ defmodule Livebook.Evaluator.IOProxy do
|
||||||
{token, state}
|
{token, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp io_request({:livebook_reference_object, object, pid}, state) do
|
||||||
|
# When the request comes from evaluator we want the pointer
|
||||||
|
# specific to the current evaluation. For any other process
|
||||||
|
# we only care about monitoring.
|
||||||
|
|
||||||
|
reference =
|
||||||
|
if pid == state.evaluator do
|
||||||
|
{pid, state.ref}
|
||||||
|
else
|
||||||
|
{pid, :process}
|
||||||
|
end
|
||||||
|
|
||||||
|
Evaluator.ObjectTracker.add_reference(state.object_tracker, object, reference)
|
||||||
|
{:ok, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp io_request({:livebook_monitor_object, object, destination, payload}, state) do
|
||||||
|
reply = Evaluator.ObjectTracker.monitor(state.object_tracker, object, destination, payload)
|
||||||
|
{reply, state}
|
||||||
|
end
|
||||||
|
|
||||||
defp io_request(_, state) do
|
defp io_request(_, state) do
|
||||||
{{:error, :request}, state}
|
{{:error, :request}, state}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
150
lib/livebook/evaluator/object_tracker.ex
Normal file
150
lib/livebook/evaluator/object_tracker.ex
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
defmodule Livebook.Evaluator.ObjectTracker do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
# This module is an abstraction for tracking objects,
|
||||||
|
# references to them and garbage collection.
|
||||||
|
#
|
||||||
|
# Every object is identified by an arbitrary unique term.
|
||||||
|
# Processes can reference those objects by adding a pair
|
||||||
|
# of `{pid, scope}`, scope is an optional additinal term
|
||||||
|
# distinguishing the reference.
|
||||||
|
#
|
||||||
|
# Each reference can be released either manually by calling
|
||||||
|
# `remove_reference/2` or automatically when the pointing
|
||||||
|
# process terminates.
|
||||||
|
#
|
||||||
|
# When all references for the given object are removed,
|
||||||
|
# all messages scheduled with `monitor/3` are sent.
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
@type state :: %{
|
||||||
|
objects: %{
|
||||||
|
object() => %{
|
||||||
|
references: list(object_reference()),
|
||||||
|
monitors: list(monitor())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@typedoc """
|
||||||
|
Arbitrary term identifying an object.
|
||||||
|
"""
|
||||||
|
@type object :: term()
|
||||||
|
|
||||||
|
@typedoc """
|
||||||
|
Reference to an object with an optional scope.
|
||||||
|
"""
|
||||||
|
@type object_reference :: {process :: pid(), scope :: term()}
|
||||||
|
|
||||||
|
@typedoc """
|
||||||
|
Scheduled message to be sent when an object is released.
|
||||||
|
"""
|
||||||
|
@type monitor :: {Process.dest(), payload :: term()}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Starts a new object tracker.
|
||||||
|
"""
|
||||||
|
@spec start_link(keyword()) :: GenServer.on_start()
|
||||||
|
def start_link(opts \\ []) do
|
||||||
|
GenServer.start_link(__MODULE__, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Adds a reference to the given object.
|
||||||
|
"""
|
||||||
|
@spec add_reference(pid(), object(), object_reference()) :: :ok
|
||||||
|
def add_reference(object_tracker, object, reference) do
|
||||||
|
GenServer.cast(object_tracker, {:add_reference, object, reference})
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Removes the given reference from all objects it is attached to.
|
||||||
|
"""
|
||||||
|
@spec remove_reference(pid(), object_reference()) :: :ok
|
||||||
|
def remove_reference(object_tracker, reference) do
|
||||||
|
GenServer.cast(object_tracker, {:remove_reference, reference})
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Schedules `payload` to be send to `destination` when the object
|
||||||
|
is released.
|
||||||
|
"""
|
||||||
|
@spec monitor(pid(), object(), Process.dest(), term()) :: :ok | {:error, :bad_object}
|
||||||
|
def monitor(object_tracker, object, destination, payload) do
|
||||||
|
GenServer.call(object_tracker, {:monitor, object, destination, payload})
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_opts) do
|
||||||
|
{:ok, %{objects: %{}}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_cast({:add_reference, object, reference}, state) do
|
||||||
|
{parent, _scope} = reference
|
||||||
|
Process.monitor(parent)
|
||||||
|
|
||||||
|
state =
|
||||||
|
if state.objects[object] do
|
||||||
|
update_in(state.objects[object].references, fn references ->
|
||||||
|
if reference in references, do: references, else: [reference | references]
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
put_in(state.objects[object], %{references: [reference], monitors: []})
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_cast({:remove_reference, reference}, state) do
|
||||||
|
state = update_references(state, fn references -> List.delete(references, reference) end)
|
||||||
|
|
||||||
|
{:noreply, garbage_collect(state)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:monitor, object, destination, payload}, _from, state) do
|
||||||
|
monitor = {destination, payload}
|
||||||
|
|
||||||
|
if state.objects[object] do
|
||||||
|
state =
|
||||||
|
update_in(state.objects[object].monitors, fn monitors ->
|
||||||
|
if monitor in monitors, do: monitors, else: [monitor | monitors]
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:reply, :ok, garbage_collect(state)}
|
||||||
|
else
|
||||||
|
{:reply, {:error, :bad_object}, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
|
||||||
|
state =
|
||||||
|
update_references(state, fn references ->
|
||||||
|
Enum.reject(references, &match?({^pid, _}, &1))
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:noreply, garbage_collect(state)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Updates references for every object with the given function
|
||||||
|
defp update_references(state, fun) do
|
||||||
|
update_in(state.objects, fn objects ->
|
||||||
|
for {object, %{references: references} = info} <- objects, into: %{} do
|
||||||
|
{object, %{info | references: fun.(references)}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp garbage_collect(state) do
|
||||||
|
{to_release, objects} = Enum.split_with(state.objects, &match?({_, %{references: []}}, &1))
|
||||||
|
|
||||||
|
for {_, %{monitors: monitors}} <- to_release, {dest, payload} <- monitors do
|
||||||
|
send(dest, payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
%{state | objects: Map.new(objects)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -23,6 +23,7 @@ defmodule Livebook.Runtime.ErlDist do
|
||||||
@required_modules [
|
@required_modules [
|
||||||
Livebook.Evaluator,
|
Livebook.Evaluator,
|
||||||
Livebook.Evaluator.IOProxy,
|
Livebook.Evaluator.IOProxy,
|
||||||
|
Livebook.Evaluator.ObjectTracker,
|
||||||
Livebook.Evaluator.DefaultFormatter,
|
Livebook.Evaluator.DefaultFormatter,
|
||||||
Livebook.Intellisense,
|
Livebook.Intellisense,
|
||||||
Livebook.Intellisense.IdentifierMatcher,
|
Livebook.Intellisense.IdentifierMatcher,
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,11 @@ defmodule Livebook.Runtime.ErlDist.EvaluatorSupervisor do
|
||||||
@doc """
|
@doc """
|
||||||
Spawns a new evaluator.
|
Spawns a new evaluator.
|
||||||
"""
|
"""
|
||||||
@spec start_evaluator(pid()) :: {:ok, Evaluator.t()} | {:error, any()}
|
@spec start_evaluator(pid(), pid()) :: {:ok, Evaluator.t()} | {:error, any()}
|
||||||
def start_evaluator(supervisor) do
|
def start_evaluator(supervisor, object_tracker) do
|
||||||
case DynamicSupervisor.start_child(
|
case DynamicSupervisor.start_child(
|
||||||
supervisor,
|
supervisor,
|
||||||
{Evaluator, [formatter: Evaluator.DefaultFormatter]}
|
{Evaluator, [formatter: Evaluator.DefaultFormatter, object_tracker: object_tracker]}
|
||||||
) do
|
) do
|
||||||
{:ok, _pid, evaluator} -> {:ok, evaluator}
|
{:ok, _pid, evaluator} -> {:ok, evaluator}
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, reason} -> {:error, reason}
|
||||||
|
|
|
||||||
|
|
@ -115,13 +115,15 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
|
||||||
|
|
||||||
{:ok, evaluator_supervisor} = ErlDist.EvaluatorSupervisor.start_link()
|
{:ok, evaluator_supervisor} = ErlDist.EvaluatorSupervisor.start_link()
|
||||||
{:ok, completion_supervisor} = Task.Supervisor.start_link()
|
{:ok, completion_supervisor} = Task.Supervisor.start_link()
|
||||||
|
{:ok, object_tracker} = Livebook.Evaluator.ObjectTracker.start_link()
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
%{
|
%{
|
||||||
owner: nil,
|
owner: nil,
|
||||||
evaluators: %{},
|
evaluators: %{},
|
||||||
evaluator_supervisor: evaluator_supervisor,
|
evaluator_supervisor: evaluator_supervisor,
|
||||||
completion_supervisor: completion_supervisor
|
completion_supervisor: completion_supervisor,
|
||||||
|
object_tracker: object_tracker
|
||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -234,7 +236,12 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
|
||||||
if Map.has_key?(state.evaluators, container_ref) do
|
if Map.has_key?(state.evaluators, container_ref) do
|
||||||
state
|
state
|
||||||
else
|
else
|
||||||
{:ok, evaluator} = ErlDist.EvaluatorSupervisor.start_evaluator(state.evaluator_supervisor)
|
{:ok, evaluator} =
|
||||||
|
ErlDist.EvaluatorSupervisor.start_evaluator(
|
||||||
|
state.evaluator_supervisor,
|
||||||
|
state.object_tracker
|
||||||
|
)
|
||||||
|
|
||||||
Process.monitor(evaluator.pid)
|
Process.monitor(evaluator.pid)
|
||||||
%{state | evaluators: Map.put(state.evaluators, container_ref, evaluator)}
|
%{state | evaluators: Map.put(state.evaluators, container_ref, evaluator)}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -619,7 +619,7 @@ defmodule Livebook.Session do
|
||||||
maybe_save_notebook_sync(state)
|
maybe_save_notebook_sync(state)
|
||||||
broadcast_message(state.session_id, :session_closed)
|
broadcast_message(state.session_id, :session_closed)
|
||||||
|
|
||||||
{:stop, :shutdown, state}
|
{:stop, :normal, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,10 @@ defmodule LivebookWeb.Output do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp render_output({:control, attrs}, %{id: id}) do
|
||||||
|
live_component(LivebookWeb.Output.ControlComponent, id: id, attrs: attrs)
|
||||||
|
end
|
||||||
|
|
||||||
defp render_output({:error, formatted, :runtime_restart_required}, %{runtime: runtime})
|
defp render_output({:error, formatted, :runtime_restart_required}, %{runtime: runtime})
|
||||||
when runtime != nil do
|
when runtime != nil do
|
||||||
assigns = %{formatted: formatted, is_standalone: Livebook.Runtime.standalone?(runtime)}
|
assigns = %{formatted: formatted, is_standalone: Livebook.Runtime.standalone?(runtime)}
|
||||||
|
|
|
||||||
81
lib/livebook_web/live/output/control_component.ex
Normal file
81
lib/livebook_web/live/output/control_component.ex
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
defmodule LivebookWeb.Output.ControlComponent do
|
||||||
|
use LivebookWeb, :live_component
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(socket) do
|
||||||
|
{:ok, assign(socket, keyboard_enabled: false)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(%{attrs: %{type: :keyboard}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="flex"
|
||||||
|
id={"#{@id}-root"}
|
||||||
|
phx-hook="KeyboardControl"
|
||||||
|
data-keydown-enabled={to_string(@keyboard_enabled and :keydown in @attrs.events)}
|
||||||
|
data-keyup-enabled={to_string(@keyboard_enabled and :keyup in @attrs.events)}
|
||||||
|
data-target={@myself}>
|
||||||
|
<span class="tooltip right" data-tooltip="Toggle keyboard control">
|
||||||
|
<button class={"button #{if @keyboard_enabled, do: "button-blue", else: "button-gray"} button-square-icon"}
|
||||||
|
type="button"
|
||||||
|
aria-label="toggle keyboard control"
|
||||||
|
phx-click={JS.push("toggle_keyboard", target: @myself)}>
|
||||||
|
<.remix_icon icon="keyboard-line" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def render(%{attrs: %{type: :button}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="flex">
|
||||||
|
<button class="button button-gray"
|
||||||
|
type="button"
|
||||||
|
phx-click={JS.push("button_click", target: @myself)}>
|
||||||
|
<%= @attrs.label %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="text-red-600">
|
||||||
|
Unknown control type <%= @attrs.type %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("toggle_keyboard", %{}, socket) do
|
||||||
|
socket = update(socket, :keyboard_enabled, ¬/1)
|
||||||
|
|
||||||
|
if :status in socket.assigns.attrs.events do
|
||||||
|
report_event(socket, %{type: :status, enabled: socket.assigns.keyboard_enabled})
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("button_click", %{}, socket) do
|
||||||
|
report_event(socket, %{type: :click})
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("keydown", %{"key" => key}, socket) do
|
||||||
|
report_event(socket, %{type: :keydown, key: key})
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("keyup", %{"key" => key}, socket) do
|
||||||
|
report_event(socket, %{type: :keyup, key: key})
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp report_event(socket, attrs) do
|
||||||
|
topic = socket.assigns.attrs.ref
|
||||||
|
event = Map.merge(%{origin: self()}, attrs)
|
||||||
|
send(socket.assigns.attrs.destination, {:event, topic, event})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -3,7 +3,9 @@ defmodule LivebookWeb.Output.FrameDynamicLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, %{"pid" => pid, "id" => id, "input_values" => input_values}, socket) do
|
def mount(_params, %{"pid" => pid, "id" => id, "input_values" => input_values}, socket) do
|
||||||
send(pid, {:connect, self()})
|
if connected?(socket) do
|
||||||
|
send(pid, {:connect, self()})
|
||||||
|
end
|
||||||
|
|
||||||
{:ok, assign(socket, id: id, output: nil, input_values: input_values)}
|
{:ok, assign(socket, id: id, output: nil, input_values: input_values)}
|
||||||
end
|
end
|
||||||
|
|
@ -78,6 +78,8 @@ defmodule LivebookWeb.Output.InputComponent do
|
||||||
name="value"
|
name="value"
|
||||||
value={@value}
|
value={@value}
|
||||||
phx-debounce="300"
|
phx-debounce="300"
|
||||||
|
phx-blur="blur"
|
||||||
|
phx-target={@myself}
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
min={@attrs.min}
|
min={@attrs.min}
|
||||||
|
|
@ -95,6 +97,8 @@ defmodule LivebookWeb.Output.InputComponent do
|
||||||
class="input h-[200px] resize-none tiny-scrollbar"
|
class="input h-[200px] resize-none tiny-scrollbar"
|
||||||
name="value"
|
name="value"
|
||||||
phx-debounce="300"
|
phx-debounce="300"
|
||||||
|
phx-blur="blur"
|
||||||
|
phx-target={@myself}
|
||||||
spellcheck="false"><%= [?\n, @value] %></textarea>
|
spellcheck="false"><%= [?\n, @value] %></textarea>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -108,13 +112,15 @@ defmodule LivebookWeb.Output.InputComponent do
|
||||||
name="value"
|
name="value"
|
||||||
value={@value}
|
value={@value}
|
||||||
phx-debounce="300"
|
phx-debounce="300"
|
||||||
|
phx-blur="blur"
|
||||||
|
phx-target={@myself}
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
autocomplete="off" />
|
autocomplete="off" />
|
||||||
</.with_password_toggle>
|
</.with_password_toggle>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp input(assigns) do
|
defp input(%{attrs: %{type: type}} = assigns) when type in [:number, :color, :url, :text] do
|
||||||
~H"""
|
~H"""
|
||||||
<input type={html_input_type(@attrs.type)}
|
<input type={html_input_type(@attrs.type)}
|
||||||
data-element="input"
|
data-element="input"
|
||||||
|
|
@ -122,10 +128,18 @@ defmodule LivebookWeb.Output.InputComponent do
|
||||||
name="value"
|
name="value"
|
||||||
value={to_string(@value)}
|
value={to_string(@value)}
|
||||||
phx-debounce="300"
|
phx-debounce="300"
|
||||||
spellcheck="false"
|
|
||||||
autocomplete="off"
|
|
||||||
phx-blur="blur"
|
phx-blur="blur"
|
||||||
phx-target={@myself} />
|
phx-target={@myself}
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off" />
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp input(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="text-red-600">
|
||||||
|
Unknown input type <%= @attrs.type %>
|
||||||
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -139,7 +153,9 @@ defmodule LivebookWeb.Output.InputComponent do
|
||||||
{:noreply, handle_html_value(socket, html_value)}
|
{:noreply, handle_html_value(socket, html_value)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("blur", %{}, socket) do
|
def handle_event("blur", %{"value" => html_value}, socket) do
|
||||||
|
socket = handle_html_value(socket, html_value)
|
||||||
|
|
||||||
if socket.assigns.error do
|
if socket.assigns.error do
|
||||||
{:noreply, assign(socket, value: socket.assigns.initial_value, error: nil)}
|
{:noreply, assign(socket, value: socket.assigns.initial_value, error: nil)}
|
||||||
else
|
else
|
||||||
|
|
@ -154,9 +170,15 @@ defmodule LivebookWeb.Output.InputComponent do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_html_value(socket, html_value) do
|
defp handle_html_value(socket, html_value) do
|
||||||
|
current_value = socket.assigns.value
|
||||||
|
|
||||||
case parse(html_value, socket.assigns.attrs) do
|
case parse(html_value, socket.assigns.attrs) do
|
||||||
|
{:ok, ^current_value} ->
|
||||||
|
socket
|
||||||
|
|
||||||
{:ok, value} ->
|
{:ok, value} ->
|
||||||
send(self(), {:set_input_value, socket.assigns.attrs.id, value})
|
send(self(), {:set_input_value, socket.assigns.attrs.id, value})
|
||||||
|
report_event(socket, value)
|
||||||
assign(socket, value: value, error: nil)
|
assign(socket, value: value, error: nil)
|
||||||
|
|
||||||
{:error, error, value} ->
|
{:error, error, value} ->
|
||||||
|
|
@ -223,4 +245,10 @@ defmodule LivebookWeb.Output.InputComponent do
|
||||||
defp parse(html_value, %{type: :color}) do
|
defp parse(html_value, %{type: :color}) do
|
||||||
{:ok, html_value}
|
{:ok, html_value}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp report_event(socket, value) do
|
||||||
|
topic = socket.assigns.attrs.ref
|
||||||
|
event = %{value: value, origin: self(), type: :change}
|
||||||
|
send(socket.assigns.attrs.destination, {:event, topic, event})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ defmodule LivebookWeb.Output.TableDynamicLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, %{"pid" => pid, "id" => id}, socket) do
|
def mount(_params, %{"pid" => pid, "id" => id}, socket) do
|
||||||
send(pid, {:connect, self()})
|
if connected?(socket) do
|
||||||
|
send(pid, {:connect, self()})
|
||||||
|
end
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
assign(socket,
|
assign(socket,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ defmodule LivebookWeb.Output.VegaLiteDynamicLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, %{"pid" => pid, "id" => id}, socket) do
|
def mount(_params, %{"pid" => pid, "id" => id}, socket) do
|
||||||
send(pid, {:connect, self()})
|
if connected?(socket) do
|
||||||
|
send(pid, {:connect, self()})
|
||||||
|
end
|
||||||
|
|
||||||
{:ok, assign(socket, id: id)}
|
{:ok, assign(socket, id: id)}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
defmodule Livebook.Evaluator.IOProxyTest do
|
defmodule Livebook.Evaluator.IOProxyTest do
|
||||||
use ExUnit.Case, async: true
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Livebook.Evaluator
|
||||||
alias Livebook.Evaluator.IOProxy
|
alias Livebook.Evaluator.IOProxy
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
{:ok, io} = IOProxy.start_link()
|
# {:ok, io} = IOProxy.start_link()
|
||||||
|
|
||||||
|
{:ok, object_tracker} = start_supervised(Evaluator.ObjectTracker)
|
||||||
|
{:ok, _pid, evaluator} = start_supervised({Evaluator, [object_tracker: object_tracker]})
|
||||||
|
io = Process.info(evaluator.pid)[:group_leader]
|
||||||
IOProxy.configure(io, self(), :ref)
|
IOProxy.configure(io, self(), :ref)
|
||||||
%{io: io}
|
%{io: io}
|
||||||
end
|
end
|
||||||
|
|
@ -87,18 +92,6 @@ defmodule Livebook.Evaluator.IOProxyTest do
|
||||||
assert_received {:evaluation_output, :ref, {:text, "[1, 2, 3]"}}
|
assert_received {:evaluation_output, :ref, {:text, "[1, 2, 3]"}}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "flush_widgets/1 returns new widget pids", %{io: io} do
|
|
||||||
widget1_pid = IEx.Helpers.pid(0, 0, 0)
|
|
||||||
widget2_pid = IEx.Helpers.pid(0, 0, 1)
|
|
||||||
|
|
||||||
livebook_put_output(io, {:vega_lite_dynamic, widget1_pid})
|
|
||||||
livebook_put_output(io, {:vega_lite_dynamic, widget2_pid})
|
|
||||||
livebook_put_output(io, {:vega_lite_dynamic, widget1_pid})
|
|
||||||
|
|
||||||
assert IOProxy.flush_widgets(io) == MapSet.new([widget1_pid, widget2_pid])
|
|
||||||
assert IOProxy.flush_widgets(io) == MapSet.new()
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "token requests" do
|
describe "token requests" do
|
||||||
test "returns different tokens for subsequent calls", %{io: io} do
|
test "returns different tokens for subsequent calls", %{io: io} do
|
||||||
IOProxy.configure(io, self(), :ref1)
|
IOProxy.configure(io, self(), :ref1)
|
||||||
|
|
|
||||||
57
test/livebook/evaluator/object_tracker_test.exs
Normal file
57
test/livebook/evaluator/object_tracker_test.exs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
defmodule Livebook.Evaluator.ObjecTrackerTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Livebook.Evaluator.ObjectTracker
|
||||||
|
|
||||||
|
setup do
|
||||||
|
{:ok, object_tracker} = start_supervised(ObjectTracker)
|
||||||
|
%{object_tracker: object_tracker}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "monitor/4 returns an error when the given object doesn't exist",
|
||||||
|
%{object_tracker: object_tracker} do
|
||||||
|
assert {:error, :bad_object} =
|
||||||
|
ObjectTracker.monitor(object_tracker, :object1, self(), :object1_released)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sends scheduled monitor messages when all object references are released",
|
||||||
|
%{object_tracker: object_tracker} do
|
||||||
|
ObjectTracker.add_reference(object_tracker, :object1, {self(), :ref1})
|
||||||
|
ObjectTracker.add_reference(object_tracker, :object1, {self(), :ref2})
|
||||||
|
|
||||||
|
ObjectTracker.monitor(object_tracker, :object1, self(), :object1_released)
|
||||||
|
|
||||||
|
ObjectTracker.remove_reference(object_tracker, {self(), :ref1})
|
||||||
|
ObjectTracker.remove_reference(object_tracker, {self(), :ref2})
|
||||||
|
|
||||||
|
assert_receive :object1_released
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not execute hooks when other references still point to the object",
|
||||||
|
%{object_tracker: object_tracker} do
|
||||||
|
ObjectTracker.add_reference(object_tracker, :object1, {self(), :ref1})
|
||||||
|
ObjectTracker.add_reference(object_tracker, :object1, {self(), :ref2})
|
||||||
|
|
||||||
|
ObjectTracker.monitor(object_tracker, :object1, self(), :object1_released)
|
||||||
|
|
||||||
|
ObjectTracker.remove_reference(object_tracker, {self(), :ref1})
|
||||||
|
|
||||||
|
refute_receive :object1_released
|
||||||
|
end
|
||||||
|
|
||||||
|
test "removes a reference if its process terminates", %{object_tracker: object_tracker} do
|
||||||
|
reference_pid =
|
||||||
|
spawn(fn ->
|
||||||
|
receive do
|
||||||
|
:stop -> :ok
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
ObjectTracker.add_reference(object_tracker, :object1, {reference_pid, :ref1})
|
||||||
|
|
||||||
|
ObjectTracker.monitor(object_tracker, :object1, self(), :object1_released)
|
||||||
|
|
||||||
|
send(reference_pid, :stop)
|
||||||
|
assert_receive :object1_released
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -4,8 +4,9 @@ defmodule Livebook.EvaluatorTest do
|
||||||
alias Livebook.Evaluator
|
alias Livebook.Evaluator
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
{:ok, _pid, evaluator} = start_supervised(Evaluator)
|
{:ok, object_tracker} = start_supervised(Evaluator.ObjectTracker)
|
||||||
%{evaluator: evaluator}
|
{:ok, _pid, evaluator} = start_supervised({Evaluator, [object_tracker: object_tracker]})
|
||||||
|
%{evaluator: evaluator, object_tracker: object_tracker}
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "evaluate_code/6" do
|
describe "evaluate_code/6" do
|
||||||
|
|
@ -161,8 +162,9 @@ defmodule Livebook.EvaluatorTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "kills widgets that that no evaluation points to", %{evaluator: evaluator} do
|
test "kills widgets that that no evaluation points to", %{evaluator: evaluator} do
|
||||||
# Evaluate the code twice, which spawns two widget processes
|
# Evaluate the code twice, each time a new widget is spawned.
|
||||||
# First of them should be eventually killed
|
# The evaluation reference is the same, so the second one overrides
|
||||||
|
# the first one and the first widget should eventually be kiled.
|
||||||
|
|
||||||
Evaluator.evaluate_code(evaluator, self(), spawn_widget_code(), :code_1)
|
Evaluator.evaluate_code(evaluator, self(), spawn_widget_code(), :code_1)
|
||||||
|
|
||||||
|
|
@ -176,27 +178,26 @@ defmodule Livebook.EvaluatorTest do
|
||||||
assert_receive {:evaluation_response, :code_1, {:ok, widget_pid2},
|
assert_receive {:evaluation_response, :code_1, {:ok, widget_pid2},
|
||||||
%{evaluation_time_ms: _time_ms}}
|
%{evaluation_time_ms: _time_ms}}
|
||||||
|
|
||||||
assert_receive {:DOWN, ^ref, :process, ^widget_pid1, :shutdown}
|
assert_receive {:DOWN, ^ref, :process, ^widget_pid1, _reason}
|
||||||
|
|
||||||
assert Process.alive?(widget_pid2)
|
assert Process.alive?(widget_pid2)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not kill a widget if another evaluation points to it", %{evaluator: evaluator} do
|
test "kills widgets when the spawning process terminates", %{evaluator: evaluator} do
|
||||||
Evaluator.evaluate_code(evaluator, self(), spawn_widget_code(), :code_1)
|
# The widget is spawned from a process that terminates,
|
||||||
|
# so the widget should terminate immediately as well
|
||||||
|
|
||||||
|
Evaluator.evaluate_code(
|
||||||
|
evaluator,
|
||||||
|
self(),
|
||||||
|
spawn_widget_from_terminating_process_code(),
|
||||||
|
:code_1
|
||||||
|
)
|
||||||
|
|
||||||
assert_receive {:evaluation_response, :code_1, {:ok, widget_pid1},
|
assert_receive {:evaluation_response, :code_1, {:ok, widget_pid1},
|
||||||
%{evaluation_time_ms: _time_ms}}
|
%{evaluation_time_ms: _time_ms}}
|
||||||
|
|
||||||
Evaluator.evaluate_code(evaluator, self(), spawn_widget_code(), :code_2)
|
refute Process.alive?(widget_pid1)
|
||||||
|
|
||||||
assert_receive {:evaluation_response, :code_2, {:ok, widget_pid2},
|
|
||||||
%{evaluation_time_ms: _time_ms}}
|
|
||||||
|
|
||||||
ref = Process.monitor(widget_pid1)
|
|
||||||
refute_receive {:DOWN, ^ref, :process, ^widget_pid1, :shutdown}
|
|
||||||
|
|
||||||
assert Process.alive?(widget_pid1)
|
|
||||||
assert Process.alive?(widget_pid2)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -225,7 +226,7 @@ defmodule Livebook.EvaluatorTest do
|
||||||
ref = Process.monitor(widget_pid1)
|
ref = Process.monitor(widget_pid1)
|
||||||
Evaluator.forget_evaluation(evaluator, :code_1)
|
Evaluator.forget_evaluation(evaluator, :code_1)
|
||||||
|
|
||||||
assert_receive {:DOWN, ^ref, :process, ^widget_pid1, :shutdown}
|
assert_receive {:DOWN, ^ref, :process, ^widget_pid1, _reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -262,8 +263,10 @@ defmodule Livebook.EvaluatorTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "initialize_from/3" do
|
describe "initialize_from/3" do
|
||||||
setup do
|
setup %{object_tracker: object_tracker} do
|
||||||
{:ok, _pid, parent_evaluator} = start_supervised(Evaluator, id: :parent_evaluator)
|
{:ok, _pid, parent_evaluator} =
|
||||||
|
start_supervised({Evaluator, [object_tracker: object_tracker]}, id: :parent_evaluator)
|
||||||
|
|
||||||
%{parent_evaluator: parent_evaluator}
|
%{parent_evaluator: parent_evaluator}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -299,16 +302,20 @@ defmodule Livebook.EvaluatorTest do
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
# Returns a code that spawns and renders a widget process
|
# Returns a code that spawns a widget process, registers
|
||||||
# and returns its pid from the evaluation
|
# a pointer for it and adds monitoring, then returns widget
|
||||||
|
# pid from the evaluation
|
||||||
defp spawn_widget_code() do
|
defp spawn_widget_code() do
|
||||||
"""
|
"""
|
||||||
widget_pid = spawn(fn ->
|
widget_pid = spawn(fn ->
|
||||||
Process.sleep(:infinity)
|
receive do
|
||||||
|
:stop -> :ok
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
ref = make_ref()
|
ref = make_ref()
|
||||||
send(Process.group_leader(), {:io_request, self(), ref, {:livebook_put_output, {:vega_lite_dynamic, widget_pid}}})
|
send(Process.group_leader(), {:io_request, self(), ref, {:livebook_reference_object, widget_pid, self()}})
|
||||||
|
send(Process.group_leader(), {:io_request, self(), ref, {:livebook_monitor_object, widget_pid, widget_pid, :stop}})
|
||||||
|
|
||||||
receive do
|
receive do
|
||||||
{:io_reply, ^ref, :ok} -> :ok
|
{:io_reply, ^ref, :ok} -> :ok
|
||||||
|
|
@ -317,4 +324,33 @@ defmodule Livebook.EvaluatorTest do
|
||||||
widget_pid
|
widget_pid
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp spawn_widget_from_terminating_process_code() do
|
||||||
|
"""
|
||||||
|
parent = self()
|
||||||
|
|
||||||
|
# Arbitrary process that spawns the widget and terminates afterwards
|
||||||
|
spawn(fn ->
|
||||||
|
widget_pid = spawn(fn ->
|
||||||
|
receive do
|
||||||
|
:stop -> :ok
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
ref = make_ref()
|
||||||
|
send(Process.group_leader(), {:io_request, self(), ref, {:livebook_reference_object, widget_pid, self()}})
|
||||||
|
send(Process.group_leader(), {:io_request, self(), ref, {:livebook_monitor_object, widget_pid, widget_pid, :stop}})
|
||||||
|
|
||||||
|
receive do
|
||||||
|
{:io_reply, ^ref, :ok} -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
send(parent, {:widget_pid, widget_pid})
|
||||||
|
end)
|
||||||
|
|
||||||
|
receive do
|
||||||
|
{:widget_pid, widget_pid} -> widget_pid
|
||||||
|
end
|
||||||
|
"""
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -167,14 +167,18 @@ defmodule LivebookWeb.SessionLiveTest do
|
||||||
assert %{notebook: %{sections: [%{cells: []}]}} = Session.get_data(session.pid)
|
assert %{notebook: %{sections: [%{cells: []}]}} = Session.get_data(session.pid)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "editing input field in cell output", %{conn: conn, session: session} do
|
test "editing input field in cell output", %{conn: conn, session: session, test: test} do
|
||||||
section_id = insert_section(session.pid)
|
section_id = insert_section(session.pid)
|
||||||
|
|
||||||
|
Process.register(self(), test)
|
||||||
|
|
||||||
insert_cell_with_input(session.pid, section_id, %{
|
insert_cell_with_input(session.pid, section_id, %{
|
||||||
|
ref: :reference,
|
||||||
id: "input1",
|
id: "input1",
|
||||||
type: :number,
|
type: :number,
|
||||||
label: "Name",
|
label: "Name",
|
||||||
default: "hey"
|
default: "hey",
|
||||||
|
destination: test
|
||||||
})
|
})
|
||||||
|
|
||||||
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
|
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
|
||||||
|
|
@ -186,14 +190,18 @@ defmodule LivebookWeb.SessionLiveTest do
|
||||||
assert %{input_values: %{"input1" => 10}} = Session.get_data(session.pid)
|
assert %{input_values: %{"input1" => 10}} = Session.get_data(session.pid)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "newlines in text input are normalized", %{conn: conn, session: session} do
|
test "newlines in text input are normalized", %{conn: conn, session: session, test: test} do
|
||||||
section_id = insert_section(session.pid)
|
section_id = insert_section(session.pid)
|
||||||
|
|
||||||
|
Process.register(self(), test)
|
||||||
|
|
||||||
insert_cell_with_input(session.pid, section_id, %{
|
insert_cell_with_input(session.pid, section_id, %{
|
||||||
|
ref: :reference,
|
||||||
id: "input1",
|
id: "input1",
|
||||||
type: :textarea,
|
type: :textarea,
|
||||||
label: "Name",
|
label: "Name",
|
||||||
default: "hey"
|
default: "hey",
|
||||||
|
destination: test
|
||||||
})
|
})
|
||||||
|
|
||||||
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
|
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
|
||||||
|
|
@ -705,7 +713,8 @@ defmodule LivebookWeb.SessionLiveTest do
|
||||||
quote do
|
quote do
|
||||||
send(
|
send(
|
||||||
Process.group_leader(),
|
Process.group_leader(),
|
||||||
{:io_request, self(), make_ref(), {:livebook_put_output, {:input, unquote(input)}}}
|
{:io_request, self(), make_ref(),
|
||||||
|
{:livebook_put_output, {:input, unquote(Macro.escape(input))}}}
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|> Macro.to_string()
|
|> Macro.to_string()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue