Use Phoenix.Tracker to keep track of sessions within the cluster (#540)

* Use Phoenix.Tracker to keep track of sessions within the cluster

* Apply review comments

* Cleanup topics and updates

* Update lib/livebook_web/live/session_live.ex
This commit is contained in:
Jonatan Kłosko 2021-09-04 19:16:01 +02:00 committed by GitHub
parent f83e51409a
commit 4ff1ff0d5a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 959 additions and 842 deletions

View file

@ -15,8 +15,10 @@ defmodule Livebook.Application do
LivebookWeb.Telemetry,
# Start the PubSub system
{Phoenix.PubSub, name: Livebook.PubSub},
# Start the tracker server on this node
{Livebook.Tracker, pubsub_server: Livebook.PubSub},
# Start the supervisor dynamically managing sessions
Livebook.SessionSupervisor,
{DynamicSupervisor, name: Livebook.SessionSupervisor, strategy: :one_for_one},
# Start the server responsible for associating files with sessions
Livebook.Session.FileGuard,
# Start the Node Pool for managing node names

View file

@ -43,6 +43,11 @@ defmodule Livebook.Session do
# for a single specific evaluation context we make sure to copy
# as little memory as necessary.
# The struct holds the basic session information that we track
# and pass around. The notebook and evaluation state is kept
# within the process state.
defstruct [:id, :pid, :origin, :notebook_name, :file, :images_dir]
use GenServer, restart: :temporary
alias Livebook.Session.{Data, FileGuard}
@ -50,6 +55,15 @@ defmodule Livebook.Session do
alias Livebook.Users.User
alias Livebook.Notebook.{Cell, Section}
@type t :: %__MODULE__{
id: id(),
pid: pid(),
origin: {:file, FileSystem.File.t()} | {:url, String.t()} | nil,
notebook_name: String.t(),
file: FileSystem.File.t() | nil,
images_dir: FileSystem.File.t()
}
@type state :: %{
session_id: id(),
data: Data.t(),
@ -58,15 +72,6 @@ defmodule Livebook.Session do
save_task_pid: pid() | nil
}
@type summary :: %{
session_id: id(),
pid: pid(),
notebook_name: String.t(),
file: FileSystem.File.t() | nil,
images_dir: FileSystem.File.t(),
origin: {:file, FileSystem.File.t()} | {:url, String.t()} | nil
}
@typedoc """
An id assigned to every running session process.
"""
@ -75,12 +80,11 @@ defmodule Livebook.Session do
## API
@doc """
Starts the server process and registers it globally using the `:global` module,
so that it's identifiable by the given id.
Starts a session server process.
## Options
* `:id` (**required**) - a unique identifier to register the session under
* `:id` (**required**) - a unique session identifier
* `:notebook` - the initial `Notebook` structure (e.g. imported from a file)
@ -94,22 +98,17 @@ defmodule Livebook.Session do
* `:images` - a map from image name to its binary content, an alternative
to `:copy_images_from` when the images are in memory
"""
@spec start_link(keyword()) :: GenServer.on_start()
@spec start_link(keyword()) :: {:ok, pid} | {:error, any()}
def start_link(opts) do
id = Keyword.fetch!(opts, :id)
GenServer.start_link(__MODULE__, opts, name: name(id))
end
defp name(session_id) do
{:global, {:session, session_id}}
GenServer.start_link(__MODULE__, opts)
end
@doc """
Returns session pid given its id.
Fetches session information from the session server.
"""
@spec get_pid(id()) :: pid() | nil
def get_pid(session_id) do
GenServer.whereis(name(session_id))
@spec get_by_pid(pid()) :: Session.t()
def get_by_pid(pid) do
GenServer.call(pid, :describe_self)
end
@doc """
@ -121,162 +120,153 @@ defmodule Livebook.Session do
keep in sync with the server by subscribing to the `sessions:id` topic
and receiving operations to apply.
"""
@spec register_client(id(), pid(), User.t()) :: Data.t()
def register_client(session_id, client_pid, user) do
GenServer.call(name(session_id), {:register_client, client_pid, user})
@spec register_client(pid(), pid(), User.t()) :: Data.t()
def register_client(pid, client_pid, user) do
GenServer.call(pid, {:register_client, client_pid, user})
end
@doc """
Returns data of the given session.
"""
@spec get_data(id()) :: Data.t()
def get_data(session_id) do
GenServer.call(name(session_id), :get_data)
end
@doc """
Returns basic information about the given session.
"""
@spec get_summary(id()) :: summary()
def get_summary(session_id) do
GenServer.call(name(session_id), :get_summary)
@spec get_data(pid()) :: Data.t()
def get_data(pid) do
GenServer.call(pid, :get_data)
end
@doc """
Returns the current notebook structure.
"""
@spec get_notebook(id()) :: Notebook.t()
def get_notebook(session_id) do
GenServer.call(name(session_id), :get_notebook)
@spec get_notebook(pid()) :: Notebook.t()
def get_notebook(pid) do
GenServer.call(pid, :get_notebook)
end
@doc """
Asynchronously sends notebook attributes update to the server.
"""
@spec set_notebook_attributes(id(), map()) :: :ok
def set_notebook_attributes(session_id, attrs) do
GenServer.cast(name(session_id), {:set_notebook_attributes, self(), attrs})
@spec set_notebook_attributes(pid(), map()) :: :ok
def set_notebook_attributes(pid, attrs) do
GenServer.cast(pid, {:set_notebook_attributes, self(), attrs})
end
@doc """
Asynchronously sends section insertion request to the server.
"""
@spec insert_section(id(), non_neg_integer()) :: :ok
def insert_section(session_id, index) do
GenServer.cast(name(session_id), {:insert_section, self(), index})
@spec insert_section(pid(), non_neg_integer()) :: :ok
def insert_section(pid, index) do
GenServer.cast(pid, {:insert_section, self(), index})
end
@doc """
Asynchronously sends section insertion request to the server.
"""
@spec insert_section_into(id(), Section.id(), non_neg_integer()) :: :ok
def insert_section_into(session_id, section_id, index) do
GenServer.cast(name(session_id), {:insert_section_into, self(), section_id, index})
@spec insert_section_into(pid(), Section.id(), non_neg_integer()) :: :ok
def insert_section_into(pid, section_id, index) do
GenServer.cast(pid, {:insert_section_into, self(), section_id, index})
end
@doc """
Asynchronously sends parent update request to the server.
"""
@spec set_section_parent(id(), Section.id(), Section.id()) :: :ok
def set_section_parent(session_id, section_id, parent_id) do
GenServer.cast(name(session_id), {:set_section_parent, self(), section_id, parent_id})
@spec set_section_parent(pid(), Section.id(), Section.id()) :: :ok
def set_section_parent(pid, section_id, parent_id) do
GenServer.cast(pid, {:set_section_parent, self(), section_id, parent_id})
end
@doc """
Asynchronously sends parent update request to the server.
"""
@spec unset_section_parent(id(), Section.id()) :: :ok
def unset_section_parent(session_id, section_id) do
GenServer.cast(name(session_id), {:unset_section_parent, self(), section_id})
@spec unset_section_parent(pid(), Section.id()) :: :ok
def unset_section_parent(pid, section_id) do
GenServer.cast(pid, {:unset_section_parent, self(), section_id})
end
@doc """
Asynchronously sends cell insertion request to the server.
"""
@spec insert_cell(id(), Section.id(), non_neg_integer(), Cell.type()) ::
:ok
def insert_cell(session_id, section_id, index, type) do
GenServer.cast(name(session_id), {:insert_cell, self(), section_id, index, type})
@spec insert_cell(pid(), Section.id(), non_neg_integer(), Cell.type()) :: :ok
def insert_cell(pid, section_id, index, type) do
GenServer.cast(pid, {:insert_cell, self(), section_id, index, type})
end
@doc """
Asynchronously sends section deletion request to the server.
"""
@spec delete_section(id(), Section.id(), boolean()) :: :ok
def delete_section(session_id, section_id, delete_cells) do
GenServer.cast(name(session_id), {:delete_section, self(), section_id, delete_cells})
@spec delete_section(pid(), Section.id(), boolean()) :: :ok
def delete_section(pid, section_id, delete_cells) do
GenServer.cast(pid, {:delete_section, self(), section_id, delete_cells})
end
@doc """
Asynchronously sends cell deletion request to the server.
"""
@spec delete_cell(id(), Cell.id()) :: :ok
def delete_cell(session_id, cell_id) do
GenServer.cast(name(session_id), {:delete_cell, self(), cell_id})
@spec delete_cell(pid(), Cell.id()) :: :ok
def delete_cell(pid, cell_id) do
GenServer.cast(pid, {:delete_cell, self(), cell_id})
end
@doc """
Asynchronously sends cell restoration request to the server.
"""
@spec restore_cell(id(), Cell.id()) :: :ok
def restore_cell(session_id, cell_id) do
GenServer.cast(name(session_id), {:restore_cell, self(), cell_id})
@spec restore_cell(pid(), Cell.id()) :: :ok
def restore_cell(pid, cell_id) do
GenServer.cast(pid, {:restore_cell, self(), cell_id})
end
@doc """
Asynchronously sends cell move request to the server.
"""
@spec move_cell(id(), Cell.id(), integer()) :: :ok
def move_cell(session_id, cell_id, offset) do
GenServer.cast(name(session_id), {:move_cell, self(), cell_id, offset})
@spec move_cell(pid(), Cell.id(), integer()) :: :ok
def move_cell(pid, cell_id, offset) do
GenServer.cast(pid, {:move_cell, self(), cell_id, offset})
end
@doc """
Asynchronously sends section move request to the server.
"""
@spec move_section(id(), Section.id(), integer()) :: :ok
def move_section(session_id, section_id, offset) do
GenServer.cast(name(session_id), {:move_section, self(), section_id, offset})
@spec move_section(pid(), Section.id(), integer()) :: :ok
def move_section(pid, section_id, offset) do
GenServer.cast(pid, {:move_section, self(), section_id, offset})
end
@doc """
Asynchronously sends cell evaluation request to the server.
"""
@spec queue_cell_evaluation(id(), Cell.id()) :: :ok
def queue_cell_evaluation(session_id, cell_id) do
GenServer.cast(name(session_id), {:queue_cell_evaluation, self(), cell_id})
@spec queue_cell_evaluation(pid(), Cell.id()) :: :ok
def queue_cell_evaluation(pid, cell_id) do
GenServer.cast(pid, {:queue_cell_evaluation, self(), cell_id})
end
@doc """
Asynchronously sends cell evaluation cancellation request to the server.
"""
@spec cancel_cell_evaluation(id(), Cell.id()) :: :ok
def cancel_cell_evaluation(session_id, cell_id) do
GenServer.cast(name(session_id), {:cancel_cell_evaluation, self(), cell_id})
@spec cancel_cell_evaluation(pid(), Cell.id()) :: :ok
def cancel_cell_evaluation(pid, cell_id) do
GenServer.cast(pid, {:cancel_cell_evaluation, self(), cell_id})
end
@doc """
Asynchronously sends notebook name update request to the server.
"""
@spec set_notebook_name(id(), String.t()) :: :ok
def set_notebook_name(session_id, name) do
GenServer.cast(name(session_id), {:set_notebook_name, self(), name})
@spec set_notebook_name(pid(), String.t()) :: :ok
def set_notebook_name(pid, name) do
GenServer.cast(pid, {:set_notebook_name, self(), name})
end
@doc """
Asynchronously sends section name update request to the server.
"""
@spec set_section_name(id(), Section.id(), String.t()) :: :ok
def set_section_name(session_id, section_id, name) do
GenServer.cast(name(session_id), {:set_section_name, self(), section_id, name})
@spec set_section_name(pid(), Section.id(), String.t()) :: :ok
def set_section_name(pid, section_id, name) do
GenServer.cast(pid, {:set_section_name, self(), section_id, name})
end
@doc """
Asynchronously sends a cell delta to apply to the server.
"""
@spec apply_cell_delta(id(), Cell.id(), Delta.t(), Data.cell_revision()) :: :ok
def apply_cell_delta(session_id, cell_id, delta, revision) do
GenServer.cast(name(session_id), {:apply_cell_delta, self(), cell_id, delta, revision})
@spec apply_cell_delta(pid(), Cell.id(), Delta.t(), Data.cell_revision()) :: :ok
def apply_cell_delta(pid, cell_id, delta, revision) do
GenServer.cast(pid, {:apply_cell_delta, self(), cell_id, delta, revision})
end
@doc """
@ -284,17 +274,17 @@ defmodule Livebook.Session do
This helps to remove old deltas that are no longer necessary.
"""
@spec report_cell_revision(id(), Cell.id(), Data.cell_revision()) :: :ok
def report_cell_revision(session_id, cell_id, revision) do
GenServer.cast(name(session_id), {:report_cell_revision, self(), cell_id, revision})
@spec report_cell_revision(pid(), Cell.id(), Data.cell_revision()) :: :ok
def report_cell_revision(pid, cell_id, revision) do
GenServer.cast(pid, {:report_cell_revision, self(), cell_id, revision})
end
@doc """
Asynchronously sends a cell attributes update to the server.
"""
@spec set_cell_attributes(id(), Cell.id(), map()) :: :ok
def set_cell_attributes(session_id, cell_id, attrs) do
GenServer.cast(name(session_id), {:set_cell_attributes, self(), cell_id, attrs})
@spec set_cell_attributes(pid(), Cell.id(), map()) :: :ok
def set_cell_attributes(pid, cell_id, attrs) do
GenServer.cast(pid, {:set_cell_attributes, self(), cell_id, attrs})
end
@doc """
@ -303,9 +293,9 @@ defmodule Livebook.Session do
Note that this results in initializing the corresponding remote node
with modules and processes required for evaluation.
"""
@spec connect_runtime(id(), Runtime.t()) :: :ok
def connect_runtime(session_id, runtime) do
GenServer.cast(name(session_id), {:connect_runtime, self(), runtime})
@spec connect_runtime(pid(), Runtime.t()) :: :ok
def connect_runtime(pid, runtime) do
GenServer.cast(pid, {:connect_runtime, self(), runtime})
end
@doc """
@ -313,17 +303,17 @@ defmodule Livebook.Session do
Note that this results in clearing the evaluation state.
"""
@spec disconnect_runtime(id()) :: :ok
def disconnect_runtime(session_id) do
GenServer.cast(name(session_id), {:disconnect_runtime, self()})
@spec disconnect_runtime(pid()) :: :ok
def disconnect_runtime(pid) do
GenServer.cast(pid, {:disconnect_runtime, self()})
end
@doc """
Asynchronously sends file location update request to the server.
"""
@spec set_file(id(), FileSystem.File.t() | nil) :: :ok
def set_file(session_id, file) do
GenServer.cast(name(session_id), {:set_file, self(), file})
@spec set_file(pid(), FileSystem.File.t() | nil) :: :ok
def set_file(pid, file) do
GenServer.cast(pid, {:set_file, self(), file})
end
@doc """
@ -334,17 +324,17 @@ defmodule Livebook.Session do
Note that notebooks are automatically persisted every @autosave_interval milliseconds.
"""
@spec save(id()) :: :ok
def save(session_id) do
GenServer.cast(name(session_id), :save)
@spec save(pid()) :: :ok
def save(pid) do
GenServer.cast(pid, :save)
end
@doc """
Synchronous version of `save/1`.
"""
@spec save_sync(id()) :: :ok
def save_sync(session_id) do
GenServer.call(name(session_id), :save_sync)
@spec save_sync(pid()) :: :ok
def save_sync(pid) do
GenServer.call(pid, :save_sync)
end
@doc """
@ -353,9 +343,9 @@ defmodule Livebook.Session do
This results in saving the file and broadcasting
a :closed message to the session topic.
"""
@spec close(id()) :: :ok
def close(session_id) do
GenServer.cast(name(session_id), :close)
@spec close(pid()) :: :ok
def close(pid) do
GenServer.cast(pid, :close)
end
## Callbacks
@ -432,6 +422,10 @@ defmodule Livebook.Session do
end
@impl true
def handle_call(:describe_self, _from, state) do
{:reply, self_from_state(state), state}
end
def handle_call({:register_client, client_pid, user}, _from, state) do
Process.monitor(client_pid)
@ -444,10 +438,6 @@ defmodule Livebook.Session do
{:reply, state.data, state}
end
def handle_call(:get_summary, _from, state) do
{:reply, summary_from_state(state), state}
end
def handle_call(:get_notebook, _from, state) do
{:reply, state.data.notebook, state}
end
@ -694,14 +684,14 @@ defmodule Livebook.Session do
# ---
defp summary_from_state(state) do
%{
session_id: state.session_id,
defp self_from_state(state) do
%__MODULE__{
id: state.session_id,
pid: self(),
origin: state.data.origin,
notebook_name: state.data.notebook.name,
file: state.data.file,
images_dir: images_dir_from_state(state),
origin: state.data.origin
images_dir: images_dir_from_state(state)
}
end
@ -807,6 +797,11 @@ defmodule Livebook.Session do
end
end
defp after_operation(state, _prev_state, {:set_notebook_name, _pid, _name}) do
notify_update(state)
state
end
defp after_operation(state, prev_state, {:set_file, _pid, _file}) do
prev_images_dir = images_dir_from_state(prev_state)
@ -823,6 +818,8 @@ defmodule Livebook.Session do
broadcast_error(state.session_id, "failed to copy images - #{message}")
end
notify_update(state)
state
end
@ -948,6 +945,12 @@ defmodule Livebook.Session do
Phoenix.PubSub.broadcast(Livebook.PubSub, "sessions:#{session_id}", message)
end
defp notify_update(state) do
session = self_from_state(state)
Livebook.Sessions.update_session(session)
broadcast_message(state.session_id, {:session_updated, session})
end
defp maybe_save_notebook_async(state) do
if should_save_notebook?(state) do
pid = self()

View file

@ -1,106 +0,0 @@
defmodule Livebook.SessionSupervisor do
@moduledoc false
# Supervisor responsible for managing running notebook sessions.
#
# Allows for creating new session processes on demand
# and managing them using random ids.
use DynamicSupervisor
alias Livebook.{Session, Utils}
@name __MODULE__
def start_link(opts \\ []) do
DynamicSupervisor.start_link(__MODULE__, opts, name: @name)
end
@impl true
def init(_opts) do
DynamicSupervisor.init(strategy: :one_for_one)
end
@doc """
Spawns a new `Session` process with the given options.
Broadcasts `{:session_created, id}` message under the `"sessions"` topic.
"""
@spec create_session(keyword()) :: {:ok, Session.id()} | {:error, any()}
def create_session(opts \\ []) do
id = Utils.random_id()
opts = Keyword.put(opts, :id, id)
case DynamicSupervisor.start_child(@name, {Session, opts}) do
{:ok, _} ->
broadcast_sessions_message({:session_created, id})
{:ok, id}
{:ok, _, _} ->
broadcast_sessions_message({:session_created, id})
{:ok, id}
:ignore ->
{:error, :ignore}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Asynchronously stops a session process identified by the given id.
Broadcasts `{:session_closed, id}` message under the `"sessions"` topic.
"""
@spec close_session(Session.id()) :: :ok
def close_session(id) do
Session.close(id)
broadcast_sessions_message({:session_closed, id})
:ok
end
defp broadcast_sessions_message(message) do
Phoenix.PubSub.broadcast(Livebook.PubSub, "sessions", message)
end
@doc """
Returns ids of all the running session processes.
"""
@spec get_session_ids() :: list(Session.id())
def get_session_ids() do
:global.registered_names()
|> Enum.flat_map(fn
{:session, id} -> [id]
_ -> []
end)
end
@doc """
Returns summaries of all the running session processes.
"""
@spec get_session_summaries() :: list(Session.summary())
def get_session_summaries() do
Enum.map(get_session_ids(), &Session.get_summary/1)
end
@doc """
Checks if a session process with the given id exists.
"""
@spec session_exists?(Session.id()) :: boolean()
def session_exists?(id) do
:global.whereis_name({:session, id}) != :undefined
end
@doc """
Retrieves pid of a session process identified by the given id.
"""
@spec get_session_pid(Session.id()) :: {:ok, pid()} | {:error, :nonexistent}
def get_session_pid(id) do
case :global.whereis_name({:session, id}) do
:undefined -> {:error, :nonexistent}
pid -> {:ok, pid}
end
end
end

83
lib/livebook/sessions.ex Normal file
View file

@ -0,0 +1,83 @@
defmodule Livebook.Sessions do
@moduledoc false
# This module is responsible for starting and discovering sessions.
#
# Every session has a server process and is described by a `%Session{}`
# info struct. Information about all sessions in the cluster is
# propagated using `Livebook.Tracker`, which serves as an ephemeral
# distributed database for the `%Session{}` structs.
alias Livebook.{Session, Utils}
@doc """
Spawns a new `Session` process with the given options.
Makes the session globally visible within the session tracker.
`{:session_created, session}` gets broadcasted under the "tracker_sessions" topic.
"""
@spec create_session(keyword()) :: {:ok, Session.t()} | {:error, any()}
def create_session(opts \\ []) do
id = Utils.random_node_aware_id()
opts = Keyword.put(opts, :id, id)
case DynamicSupervisor.start_child(Livebook.SessionSupervisor, {Session, opts}) do
{:ok, pid} ->
session = Session.get_by_pid(pid)
case Livebook.Tracker.track_session(session) do
:ok ->
{:ok, session}
{:error, reason} ->
Session.close(pid)
{:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
@doc """
Returns all the running sessions.
"""
@spec list_sessions() :: list(Session.t())
def list_sessions() do
Livebook.Tracker.list_sessions()
end
@doc """
Returns tracked session with the given id.
"""
@spec fetch_session(Session.id()) :: {:ok, Session.t()} | :error
def fetch_session(id) do
case Livebook.Tracker.fetch_session(id) do
{:ok, session} ->
{:ok, session}
:error ->
# The local tracker server doesn't know about this session,
# but it may not have propagated yet, so we extract the session
# node from id and ask the corresponding tracker directly
with {:ok, other_node} when other_node != node() <- Utils.node_from_node_aware_id(id),
{:ok, session} <- :rpc.call(other_node, Livebook.Tracker, :fetch_session, [id]) do
{:ok, session}
else
_ -> :error
end
end
end
@doc """
Updates the given session info across the cluster.
`{:session_updated, session}` gets broadcasted under the "tracker_sessions" topic.
"""
@spec update_session(Session.t()) :: :ok | {:error, any()}
def update_session(session) do
Livebook.Tracker.update_session(session)
end
end

100
lib/livebook/tracker.ex Normal file
View file

@ -0,0 +1,100 @@
defmodule Livebook.Tracker do
@moduledoc false
use Phoenix.Tracker
alias Livebook.Session
@name __MODULE__
def start_link(opts \\ []) do
opts = Keyword.merge([name: @name], opts)
Phoenix.Tracker.start_link(__MODULE__, opts, opts)
end
@sessions_topic "sessions"
@doc """
Starts tracking the given session, making it visible globally.
"""
@spec track_session(Session.t()) :: :ok | {:error, any()}
def track_session(session) do
case Phoenix.Tracker.track(@name, session.pid, @sessions_topic, session.id, %{
session: session
}) do
{:ok, _ref} -> :ok
{:error, reason} -> {:error, reason}
end
end
@doc """
Updates the tracked session object matching the given id.
"""
@spec update_session(Session.t()) :: :ok | {:error, any()}
def update_session(session) do
case Phoenix.Tracker.update(@name, session.pid, @sessions_topic, session.id, %{
session: session
}) do
{:ok, _ref} -> :ok
{:error, reason} -> {:error, reason}
end
end
@doc """
Returns all tracked sessions.
"""
@spec list_sessions() :: list(Session.t())
def list_sessions() do
presences = Phoenix.Tracker.list(@name, @sessions_topic)
for {_id, %{session: session}} <- presences, do: session
end
@doc """
Returns tracked session with the given id.
"""
@spec fetch_session(Session.id()) :: {:ok, Session.t()} | :error
def fetch_session(id) do
case Phoenix.Tracker.get_by_key(@name, @sessions_topic, id) do
[{_id, %{session: session}}] -> {:ok, session}
_ -> :error
end
end
@impl true
def init(opts) do
server = Keyword.fetch!(opts, :pubsub_server)
{:ok, %{pubsub_server: server, node_name: Phoenix.PubSub.node_name(server)}}
end
@impl true
def handle_diff(diff, state) do
for {topic, topic_diff} <- diff do
handle_topic_diff(topic, topic_diff, state)
end
{:ok, state}
end
defp handle_topic_diff(@sessions_topic, {joins, leaves}, state) do
joins = Map.new(joins)
leaves = Map.new(leaves)
messages =
for id <- Enum.uniq(Map.keys(joins) ++ Map.keys(leaves)) do
case {joins[id], leaves[id]} do
{%{session: session}, nil} -> {:session_created, session}
{nil, %{session: session}} -> {:session_closed, session}
{%{session: session}, %{}} -> {:session_updated, session}
end
end
for message <- messages do
Phoenix.PubSub.direct_broadcast!(
state.node_name,
state.pubsub_server,
"tracker_sessions",
message
)
end
end
end

View file

@ -6,7 +6,7 @@ defmodule Livebook.Utils do
@doc """
Generates a random binary id.
"""
@spec random_id() :: binary()
@spec random_id() :: id()
def random_id() do
:crypto.strong_rand_bytes(20) |> Base.encode32(case: :lower)
end
@ -14,7 +14,7 @@ defmodule Livebook.Utils do
@doc """
Generates a random short binary id.
"""
@spec random_short_id() :: binary()
@spec random_short_id() :: id()
def random_short_id() do
:crypto.strong_rand_bytes(5) |> Base.encode32(case: :lower)
end
@ -27,6 +27,50 @@ defmodule Livebook.Utils do
:crypto.strong_rand_bytes(42) |> Base.url_encode64() |> String.to_atom()
end
@doc """
Generates a random binary id that includes node information.
## Format
The id is formed from the following binary parts:
* 16B - hashed node name
* 9B - random bytes
The binary is base32 encoded.
"""
@spec random_node_aware_id() :: id()
def random_node_aware_id() do
node_part = node_hash(node())
random_part = :crypto.strong_rand_bytes(9)
binary = <<node_part::binary, random_part::binary>>
# 16B + 9B = 25B is suitable for base32 encoding without padding
Base.encode32(binary, case: :lower)
end
# Note: the result is always 16 bytes long
defp node_hash(node) do
content = Atom.to_string(node)
:erlang.md5(content)
end
@doc """
Extracts node name from the given node aware id.
The node in question must be connected, otherwise it won't be found.
"""
@spec node_from_node_aware_id(id()) :: {:ok, node()} | :error
def node_from_node_aware_id(id) do
binary = Base.decode32!(id, case: :lower)
<<node_part::binary-size(16), _random_part::binary-size(9)>> = binary
known_nodes = [node() | Node.list()]
Enum.find_value(known_nodes, :error, fn node ->
node_hash(node) == node_part && {:ok, node}
end)
end
@doc """
Converts the given name to node identifier.
"""

View file

@ -1,25 +1,28 @@
defmodule LivebookWeb.SessionController do
use LivebookWeb, :controller
alias Livebook.{SessionSupervisor, Session, FileSystem}
alias Livebook.{Sessions, Session, FileSystem}
def show_image(conn, %{"id" => id, "image" => image}) do
if SessionSupervisor.session_exists?(id) do
%{images_dir: images_dir} = Session.get_summary(id)
file = FileSystem.File.resolve(images_dir, image)
serve_static(conn, file)
else
send_resp(conn, 404, "Not found")
case Sessions.fetch_session(id) do
{:ok, session} ->
file = FileSystem.File.resolve(session.images_dir, image)
serve_static(conn, file)
:error ->
send_resp(conn, 404, "Not found")
end
end
def download_source(conn, %{"id" => id, "format" => format}) do
if SessionSupervisor.session_exists?(id) do
notebook = Session.get_notebook(id)
case Sessions.fetch_session(id) do
{:ok, session} ->
notebook = Session.get_notebook(session.pid)
send_notebook_source(conn, notebook, format)
else
send_resp(conn, 404, "Not found")
send_notebook_source(conn, notebook, format)
:error ->
send_resp(conn, 404, "Not found")
end
end

View file

@ -5,17 +5,17 @@ defmodule LivebookWeb.HomeLive do
import LivebookWeb.SessionHelpers
alias LivebookWeb.{SidebarHelpers, ExploreHelpers}
alias Livebook.{SessionSupervisor, Session, LiveMarkdown, Notebook, FileSystem}
alias Livebook.{Sessions, Session, LiveMarkdown, Notebook, FileSystem}
@impl true
def mount(_params, %{"current_user_id" => current_user_id} = session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions")
Phoenix.PubSub.subscribe(Livebook.PubSub, "tracker_sessions")
Phoenix.PubSub.subscribe(Livebook.PubSub, "users:#{current_user_id}")
end
current_user = build_current_user(session, socket)
session_summaries = sort_session_summaries(SessionSupervisor.get_session_summaries())
sessions = sort_sessions(Sessions.list_sessions())
notebook_infos = Notebook.Explore.notebook_infos() |> Enum.take(3)
{:ok,
@ -23,7 +23,7 @@ defmodule LivebookWeb.HomeLive do
current_user: current_user,
file: Livebook.Config.default_dir(),
file_info: %{exists: true, access: :read_write},
session_summaries: session_summaries,
sessions: sessions,
notebook_infos: notebook_infos
)}
end
@ -63,7 +63,7 @@ defmodule LivebookWeb.HomeLive do
id: "home-file-select",
file: @file,
extnames: [LiveMarkdown.extension()],
running_files: files(@session_summaries) do %>
running_files: files(@sessions) do %>
<div class="flex justify-end space-x-2">
<button class="button button-outlined-gray whitespace-nowrap"
phx-click="fork"
@ -71,15 +71,15 @@ defmodule LivebookWeb.HomeLive do
<.remix_icon icon="git-branch-line" class="align-middle mr-1" />
<span>Fork</span>
</button>
<%= if file_running?(@file, @session_summaries) do %>
<%= if file_running?(@file, @sessions) do %>
<%= live_redirect "Join session",
to: Routes.session_path(@socket, :page, session_id_by_file(@file, @session_summaries)),
to: Routes.session_path(@socket, :page, session_id_by_file(@file, @sessions)),
class: "button button-blue" %>
<% else %>
<span {open_button_tooltip_attrs(@file, @file_info)}>
<button class="button button-blue"
phx-click="open"
disabled={not path_openable?(@file, @file_info, @session_summaries)}>
disabled={not path_openable?(@file, @file_info, @sessions)}>
Open
</button>
</span>
@ -112,7 +112,7 @@ defmodule LivebookWeb.HomeLive do
<h2 class="mb-4 text-xl font-semibold text-gray-800">
Running sessions
</h2>
<.sessions_list session_summaries={@session_summaries} socket={@socket} />
<.sessions_list sessions={@sessions} socket={@socket} />
</div>
</div>
</div>
@ -131,7 +131,7 @@ defmodule LivebookWeb.HomeLive do
id: "close-session",
modal_class: "w-full max-w-xl",
return_to: Routes.home_path(@socket, :page),
session_summary: @session_summary %>
session: @session %>
<% end %>
<%= if @live_action == :import do %>
@ -152,7 +152,7 @@ defmodule LivebookWeb.HomeLive do
end
end
defp sessions_list(%{session_summaries: []} = assigns) do
defp sessions_list(%{sessions: []} = assigns) do
~H"""
<div class="p-5 flex space-x-4 items-center border border-gray-200 rounded-lg">
<div>
@ -170,35 +170,35 @@ defmodule LivebookWeb.HomeLive do
defp sessions_list(assigns) do
~H"""
<div class="flex flex-col space-y-4">
<%= for summary <- @session_summaries do %>
<%= for session <- @sessions do %>
<div class="p-5 flex items-center border border-gray-200 rounded-lg"
data-test-session-id={summary.session_id}>
data-test-session-id={session.id}>
<div class="flex-grow flex flex-col space-y-1">
<%= live_redirect summary.notebook_name,
to: Routes.session_path(@socket, :page, summary.session_id),
<%= live_redirect session.notebook_name,
to: Routes.session_path(@socket, :page, session.id),
class: "font-semibold text-gray-800 hover:text-gray-900" %>
<div class="text-gray-600 text-sm">
<%= if summary.file, do: summary.file.path, else: "No file" %>
<%= if session.file, do: session.file.path, else: "No file" %>
</div>
</div>
<div class="relative" id={"session-#{summary.session_id}-menu"} phx-hook="Menu" data-element="menu">
<div class="relative" id={"session-#{session.id}-menu"} phx-hook="Menu" data-element="menu">
<button class="icon-button" data-toggle>
<.remix_icon icon="more-2-fill" class="text-xl" />
</button>
<div class="menu" data-content>
<button class="menu__item text-gray-500"
phx-click="fork_session"
phx-value-id={summary.session_id}>
phx-value-id={session.id}>
<.remix_icon icon="git-branch-line" />
<span class="font-medium">Fork</span>
</button>
<a class="menu__item text-gray-500"
href={live_dashboard_process_path(@socket, summary.pid)}
href={live_dashboard_process_path(@socket, session.pid)}
target="_blank">
<.remix_icon icon="dashboard-2-line" />
<span class="font-medium">See on Dashboard</span>
</a>
<%= live_patch to: Routes.home_path(@socket, :close_session, summary.session_id),
<%= live_patch to: Routes.home_path(@socket, :close_session, session.id),
class: "menu__item text-red-600" do %>
<.remix_icon icon="close-circle-line" />
<span class="font-medium">Close</span>
@ -213,8 +213,8 @@ defmodule LivebookWeb.HomeLive do
@impl true
def handle_params(%{"session_id" => session_id}, _url, socket) do
session_summary = Enum.find(socket.assigns.session_summaries, &(&1.session_id == session_id))
{:noreply, assign(socket, session_summary: session_summary)}
session = Enum.find(socket.assigns.sessions, &(&1.id == session_id))
{:noreply, assign(socket, session: session)}
end
def handle_params(%{"tab" => tab}, _url, socket) do
@ -270,9 +270,10 @@ defmodule LivebookWeb.HomeLive do
end
def handle_event("fork_session", %{"id" => session_id}, socket) do
data = Session.get_data(session_id)
session = Enum.find(socket.assigns.sessions, &(&1.id == session_id))
%{images_dir: images_dir} = session
data = Session.get_data(session.pid)
notebook = Notebook.forked(data.notebook)
%{images_dir: images_dir} = Session.get_summary(session_id)
origin =
if data.file do
@ -303,15 +304,27 @@ defmodule LivebookWeb.HomeLive do
{:noreply, assign(socket, file: file, file_info: file_info)}
end
def handle_info({:session_created, id}, socket) do
summary = Session.get_summary(id)
session_summaries = sort_session_summaries([summary | socket.assigns.session_summaries])
{:noreply, assign(socket, session_summaries: session_summaries)}
def handle_info({:session_created, session}, socket) do
if session in socket.assigns.sessions do
{:noreply, socket}
else
sessions = sort_sessions([session | socket.assigns.sessions])
{:noreply, assign(socket, sessions: sessions)}
end
end
def handle_info({:session_closed, id}, socket) do
session_summaries = Enum.reject(socket.assigns.session_summaries, &(&1.session_id == id))
{:noreply, assign(socket, session_summaries: session_summaries)}
def handle_info({:session_updated, session}, socket) do
sessions =
Enum.map(socket.assigns.sessions, fn other ->
if other.id == session.id, do: session, else: other
end)
{:noreply, assign(socket, sessions: sessions)}
end
def handle_info({:session_closed, session}, socket) do
sessions = Enum.reject(socket.assigns.sessions, &(&1.id == session.id))
{:noreply, assign(socket, sessions: sessions)}
end
def handle_info({:import_content, content, session_opts}, socket) do
@ -330,20 +343,20 @@ defmodule LivebookWeb.HomeLive do
def handle_info(_message, socket), do: {:noreply, socket}
defp sort_session_summaries(session_summaries) do
Enum.sort_by(session_summaries, & &1.notebook_name)
defp sort_sessions(sessions) do
Enum.sort_by(sessions, & &1.notebook_name)
end
defp files(session_summaries) do
Enum.map(session_summaries, & &1.file)
defp files(sessions) do
Enum.map(sessions, & &1.file)
end
defp path_forkable?(file, file_info) do
regular?(file, file_info)
end
defp path_openable?(file, file_info, session_summaries) do
regular?(file, file_info) and not file_running?(file, session_summaries) and
defp path_openable?(file, file_info, sessions) do
regular?(file, file_info) and not file_running?(file, sessions) and
writable?(file_info)
end
@ -355,8 +368,8 @@ defmodule LivebookWeb.HomeLive do
file_info.access in [:read_write, :write]
end
defp file_running?(file, session_summaries) do
running_files = files(session_summaries)
defp file_running?(file, sessions) do
running_files = files(sessions)
file in running_files
end
@ -366,8 +379,8 @@ defmodule LivebookWeb.HomeLive do
end
end
defp session_id_by_file(file, session_summaries) do
summary = Enum.find(session_summaries, &(&1.file == file))
summary.session_id
defp session_id_by_file(file, sessions) do
session = Enum.find(sessions, &(&1.file == file))
session.id
end
end

View file

@ -1,8 +1,6 @@
defmodule LivebookWeb.HomeLive.CloseSessionComponent do
use LivebookWeb, :live_component
alias Livebook.SessionSupervisor
@impl true
def render(assigns) do
~H"""
@ -12,7 +10,7 @@ defmodule LivebookWeb.HomeLive.CloseSessionComponent do
</h3>
<p class="text-gray-700">
Are you sure you want to close this section -
<span class="font-semibold"><%= @session_summary.notebook_name %></span>?
<span class="font-semibold"><%= @session.notebook_name %></span>?
This won't delete any persisted files.
</p>
<div class="mt-8 flex justify-end space-x-2">
@ -28,7 +26,7 @@ defmodule LivebookWeb.HomeLive.CloseSessionComponent do
@impl true
def handle_event("close", %{}, socket) do
SessionSupervisor.close_session(socket.assigns.session_summary.session_id)
Livebook.Session.close(socket.assigns.session.pid)
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
end
end

View file

@ -6,7 +6,7 @@ defmodule LivebookWeb.SessionHelpers do
Creates a new session, redirects on success,
puts an error flash message on failure.
Accepts the same options as `Livebook.SessionSupervisor.create_session/1`.
Accepts the same options as `Livebook.Sessions.create_session/1`.
"""
@spec create_session(Phoenix.LiveView.Socket.t(), keyword()) :: Phoenix.LiveView.Socket.t()
def create_session(socket, opts \\ []) do
@ -19,9 +19,9 @@ defmodule LivebookWeb.SessionHelpers do
opts
end
case Livebook.SessionSupervisor.create_session(opts) do
{:ok, id} ->
push_redirect(socket, to: Routes.session_path(socket, :page, id))
case Livebook.Sessions.create_session(opts) do
{:ok, session} ->
push_redirect(socket, to: Routes.session_path(socket, :page, session.id))
{:error, reason} ->
put_flash(socket, :error, "Failed to create session: #{reason}")

View file

@ -6,49 +6,52 @@ defmodule LivebookWeb.SessionLive do
import Livebook.Utils, only: [access_by_id: 1]
alias LivebookWeb.SidebarHelpers
alias Livebook.{SessionSupervisor, Session, Delta, Notebook, Runtime, LiveMarkdown, FileSystem}
alias Livebook.{Sessions, Session, Delta, Notebook, Runtime, LiveMarkdown, FileSystem}
alias Livebook.Notebook.Cell
alias Livebook.JSInterop
@impl true
def mount(%{"id" => session_id}, %{"current_user_id" => current_user_id} = session, socket) do
if SessionSupervisor.session_exists?(session_id) do
current_user = build_current_user(session, socket)
def mount(%{"id" => session_id}, %{"current_user_id" => current_user_id} = web_session, socket) do
# We use the tracked sessions to locate the session pid, but then
# we talk to the session process exclusively for getting all the information
case Sessions.fetch_session(session_id) do
{:ok, %{pid: session_pid}} ->
current_user = build_current_user(web_session, socket)
data =
if connected?(socket) do
data = Session.register_client(session_id, self(), current_user)
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Phoenix.PubSub.subscribe(Livebook.PubSub, "users:#{current_user_id}")
data =
if connected?(socket) do
data = Session.register_client(session_pid, self(), current_user)
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Phoenix.PubSub.subscribe(Livebook.PubSub, "users:#{current_user_id}")
data
else
Session.get_data(session_id)
end
data
else
Session.get_data(session_pid)
end
session_pid = Session.get_pid(session_id)
session = Session.get_by_pid(session_pid)
platform = platform_from_socket(socket)
platform = platform_from_socket(socket)
{:ok,
socket
|> assign(
platform: platform,
session_id: session_id,
session_pid: session_pid,
current_user: current_user,
self: self(),
data_view: data_to_view(data),
autofocus_cell_id: autofocus_cell_id(data.notebook)
)
|> assign_private(data: data)
|> allow_upload(:cell_image,
accept: ~w(.jpg .jpeg .png .gif),
max_entries: 1,
max_file_size: 5_000_000
)}
else
{:ok, redirect(socket, to: Routes.home_path(socket, :page))}
{:ok,
socket
|> assign(
session: session,
platform: platform,
current_user: current_user,
self: self(),
data_view: data_to_view(data),
autofocus_cell_id: autofocus_cell_id(data.notebook)
)
|> assign_private(data: data)
|> allow_upload(:cell_image,
accept: ~w(.jpg .jpeg .png .gif),
max_entries: 1,
max_file_size: 5_000_000
)}
:error ->
{:ok, redirect(socket, to: Routes.home_path(socket, :page))}
end
end
@ -90,22 +93,22 @@ defmodule LivebookWeb.SessionLive do
<SidebarHelpers.link_item
icon="cpu-line"
label="Runtime settings (sr)"
path={Routes.session_path(@socket, :runtime_settings, @session_id)}
path={Routes.session_path(@socket, :runtime_settings, @session.id)}
active={@live_action == :runtime_settings} />
<SidebarHelpers.link_item
icon="delete-bin-6-fill"
label="Bin (sb)"
path={Routes.session_path(@socket, :bin, @session_id)}
path={Routes.session_path(@socket, :bin, @session.id)}
active={@live_action == :bin} />
<SidebarHelpers.break_item />
<SidebarHelpers.link_item
icon="keyboard-box-fill"
label="Keyboard shortcuts (?)"
path={Routes.session_path(@socket, :shortcuts, @session_id)}
path={Routes.session_path(@socket, :shortcuts, @session.id)}
active={@live_action == :shortcuts} />
<SidebarHelpers.user_item
current_user={@current_user}
path={Routes.session_path(@socket, :user, @session_id)} />
path={Routes.session_path(@socket, :user, @session.id)} />
</SidebarHelpers.sidebar>
<div class="flex flex-col h-full w-full max-w-xs absolute z-30 top-0 left-[64px] overflow-y-auto shadow-xl md:static md:shadow-none bg-gray-50 border-r border-gray-100 px-6 py-10"
data-element="side-panel">
@ -201,17 +204,17 @@ defmodule LivebookWeb.SessionLive do
<span class="font-medium">Fork</span>
</button>
<a class="menu__item text-gray-500"
href={live_dashboard_process_path(@socket, @session_pid)}
href={live_dashboard_process_path(@socket, @session.pid)}
target="_blank">
<.remix_icon icon="dashboard-2-line" />
<span class="font-medium">See on Dashboard</span>
</a>
<%= live_patch to: Routes.session_path(@socket, :export, @session_id, "livemd"),
<%= live_patch to: Routes.session_path(@socket, :export, @session.id, "livemd"),
class: "menu__item text-gray-500" do %>
<.remix_icon icon="download-2-line" />
<span class="font-medium">Export</span>
<% end %>
<%= live_patch to: Routes.home_path(@socket, :close_session, @session_id),
<%= live_patch to: Routes.home_path(@socket, :close_session, @session.id),
class: "menu__item text-red-600" do %>
<.remix_icon icon="close-circle-line" />
<span class="font-medium">Close</span>
@ -232,7 +235,7 @@ defmodule LivebookWeb.SessionLive do
<%= live_component LivebookWeb.SessionLive.SectionComponent,
id: section_view.id,
index: index,
session_id: @session_id,
session_id: @session.id,
section_view: section_view %>
<% end %>
<div style="height: 80vh"></div>
@ -241,7 +244,7 @@ defmodule LivebookWeb.SessionLive do
</div>
<div class="fixed bottom-[0.4rem] right-[1.5rem]">
<%= live_component LivebookWeb.SessionLive.IndicatorsComponent,
session_id: @session_id,
session_id: @session.id,
file: @data_view.file,
dirty: @data_view.dirty,
autosave_interval_s: @data_view.autosave_interval_s,
@ -255,15 +258,15 @@ defmodule LivebookWeb.SessionLive do
id: "user",
modal_class: "w-full max-w-sm",
user: @current_user,
return_to: Routes.session_path(@socket, :page, @session_id) %>
return_to: Routes.session_path(@socket, :page, @session.id) %>
<% end %>
<%= if @live_action == :runtime_settings do %>
<%= live_modal LivebookWeb.SessionLive.RuntimeComponent,
id: "runtime-settings",
modal_class: "w-full max-w-4xl",
return_to: Routes.session_path(@socket, :page, @session_id),
session_id: @session_id,
return_to: Routes.session_path(@socket, :page, @session.id),
session: @session,
runtime: @data_view.runtime %>
<% end %>
@ -271,9 +274,9 @@ defmodule LivebookWeb.SessionLive do
<%= live_modal @socket, LivebookWeb.SessionLive.PersistenceLive,
id: "persistence",
modal_class: "w-full max-w-4xl",
return_to: Routes.session_path(@socket, :page, @session_id),
return_to: Routes.session_path(@socket, :page, @session.id),
session: %{
"session_id" => @session_id,
"session" => @session,
"file" => @data_view.file,
"persist_outputs" => @data_view.persist_outputs,
"autosave_interval_s" => @data_view.autosave_interval_s
@ -285,54 +288,54 @@ defmodule LivebookWeb.SessionLive do
id: "shortcuts",
modal_class: "w-full max-w-6xl",
platform: @platform,
return_to: Routes.session_path(@socket, :page, @session_id) %>
return_to: Routes.session_path(@socket, :page, @session.id) %>
<% end %>
<%= if @live_action == :cell_settings do %>
<%= live_modal settings_component_for(@cell),
id: "cell-settings",
modal_class: "w-full max-w-xl",
session_id: @session_id,
session: @session,
cell: @cell,
return_to: Routes.session_path(@socket, :page, @session_id) %>
return_to: Routes.session_path(@socket, :page, @session.id) %>
<% end %>
<%= if @live_action == :cell_upload do %>
<%= live_modal LivebookWeb.SessionLive.CellUploadComponent,
id: "cell-upload",
modal_class: "w-full max-w-xl",
session_id: @session_id,
session: @session,
cell: @cell,
uploads: @uploads,
return_to: Routes.session_path(@socket, :page, @session_id) %>
return_to: Routes.session_path(@socket, :page, @session.id) %>
<% end %>
<%= if @live_action == :delete_section do %>
<%= live_modal LivebookWeb.SessionLive.DeleteSectionComponent,
id: "delete-section",
modal_class: "w-full max-w-xl",
session_id: @session_id,
session: @session,
section: @section,
is_first: @section.id == @first_section_id,
return_to: Routes.session_path(@socket, :page, @session_id) %>
return_to: Routes.session_path(@socket, :page, @session.id) %>
<% end %>
<%= if @live_action == :bin do %>
<%= live_modal LivebookWeb.SessionLive.BinComponent,
id: "bin",
modal_class: "w-full max-w-4xl",
session_id: @session_id,
session: @session,
bin_entries: @data_view.bin_entries,
return_to: Routes.session_path(@socket, :page, @session_id) %>
return_to: Routes.session_path(@socket, :page, @session.id) %>
<% end %>
<%= if @live_action == :export do %>
<%= live_modal LivebookWeb.SessionLive.ExportComponent,
id: "export",
modal_class: "w-full max-w-4xl",
session_id: @session_id,
session: @session,
tab: @tab,
return_to: Routes.session_path(@socket, :page, @session_id) %>
return_to: Routes.session_path(@socket, :page, @session.id) %>
<% end %>
"""
end
@ -427,14 +430,14 @@ defmodule LivebookWeb.SessionLive do
def handle_event("append_section", %{}, socket) do
idx = length(socket.private.data.notebook.sections)
Session.insert_section(socket.assigns.session_id, idx)
Session.insert_section(socket.assigns.session.pid, idx)
{:noreply, socket}
end
def handle_event("insert_section_into", %{"section_id" => section_id, "index" => index}, socket) do
index = ensure_integer(index) |> max(0)
Session.insert_section_into(socket.assigns.session_id, section_id, index)
Session.insert_section_into(socket.assigns.session.pid, section_id, index)
{:noreply, socket}
end
@ -444,13 +447,13 @@ defmodule LivebookWeb.SessionLive do
%{"section_id" => section_id, "parent_id" => parent_id},
socket
) do
Session.set_section_parent(socket.assigns.session_id, section_id, parent_id)
Session.set_section_parent(socket.assigns.session.pid, section_id, parent_id)
{:noreply, socket}
end
def handle_event("unset_section_parent", %{"section_id" => section_id}, socket) do
Session.unset_section_parent(socket.assigns.session_id, section_id)
Session.unset_section_parent(socket.assigns.session.pid, section_id)
{:noreply, socket}
end
@ -462,7 +465,7 @@ defmodule LivebookWeb.SessionLive do
) do
index = ensure_integer(index) |> max(0)
type = String.to_atom(type)
Session.insert_cell(socket.assigns.session_id, section_id, index, type)
Session.insert_cell(socket.assigns.session.pid, section_id, index, type)
{:noreply, socket}
end
@ -482,21 +485,21 @@ defmodule LivebookWeb.SessionLive do
end
def handle_event("delete_cell", %{"cell_id" => cell_id}, socket) do
Session.delete_cell(socket.assigns.session_id, cell_id)
Session.delete_cell(socket.assigns.session.pid, cell_id)
{:noreply, socket}
end
def handle_event("set_notebook_name", %{"name" => name}, socket) do
name = normalize_name(name)
Session.set_notebook_name(socket.assigns.session_id, name)
Session.set_notebook_name(socket.assigns.session.pid, name)
{:noreply, socket}
end
def handle_event("set_section_name", %{"section_id" => section_id, "name" => name}, socket) do
name = normalize_name(name)
Session.set_section_name(socket.assigns.session_id, section_id, name)
Session.set_section_name(socket.assigns.session.pid, section_id, name)
{:noreply, socket}
end
@ -507,7 +510,7 @@ defmodule LivebookWeb.SessionLive do
socket
) do
delta = Delta.from_compressed(delta)
Session.apply_cell_delta(socket.assigns.session_id, cell_id, delta, revision)
Session.apply_cell_delta(socket.assigns.session.pid, cell_id, delta, revision)
{:noreply, socket}
end
@ -517,7 +520,7 @@ defmodule LivebookWeb.SessionLive do
%{"cell_id" => cell_id, "revision" => revision},
socket
) do
Session.report_cell_revision(socket.assigns.session_id, cell_id, revision)
Session.report_cell_revision(socket.assigns.session.pid, cell_id, revision)
{:noreply, socket}
end
@ -527,34 +530,34 @@ defmodule LivebookWeb.SessionLive do
# to more closely imitate an actual shell
value = String.replace(value, "\r\n", "\n")
Session.set_cell_attributes(socket.assigns.session_id, cell_id, %{value: value})
Session.set_cell_attributes(socket.assigns.session.pid, cell_id, %{value: value})
{:noreply, socket}
end
def handle_event("move_cell", %{"cell_id" => cell_id, "offset" => offset}, socket) do
offset = ensure_integer(offset)
Session.move_cell(socket.assigns.session_id, cell_id, offset)
Session.move_cell(socket.assigns.session.pid, cell_id, offset)
{:noreply, socket}
end
def handle_event("move_section", %{"section_id" => section_id, "offset" => offset}, socket) do
offset = ensure_integer(offset)
Session.move_section(socket.assigns.session_id, section_id, offset)
Session.move_section(socket.assigns.session.pid, section_id, offset)
{:noreply, socket}
end
def handle_event("queue_cell_evaluation", %{"cell_id" => cell_id}, socket) do
Session.queue_cell_evaluation(socket.assigns.session_id, cell_id)
Session.queue_cell_evaluation(socket.assigns.session.pid, cell_id)
{:noreply, socket}
end
def handle_event("queue_section_cells_evaluation", %{"section_id" => section_id}, socket) do
with {:ok, section} <- Notebook.fetch_section(socket.private.data.notebook, section_id) do
for cell <- section.cells, is_struct(cell, Cell.Elixir) do
Session.queue_cell_evaluation(socket.assigns.session_id, cell.id)
Session.queue_cell_evaluation(socket.assigns.session.pid, cell.id)
end
end
@ -566,7 +569,7 @@ defmodule LivebookWeb.SessionLive do
for {cell, _} <- Notebook.elixir_cells_with_section(data.notebook),
data.cell_infos[cell.id].validity_status != :evaluated do
Session.queue_cell_evaluation(socket.assigns.session_id, cell.id)
Session.queue_cell_evaluation(socket.assigns.session.pid, cell.id)
end
{:noreply, socket}
@ -577,7 +580,7 @@ defmodule LivebookWeb.SessionLive do
Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id) do
for {cell, _} <- Notebook.child_cells_with_section(socket.private.data.notebook, cell.id),
is_struct(cell, Cell.Elixir) do
Session.queue_cell_evaluation(socket.assigns.session_id, cell.id)
Session.queue_cell_evaluation(socket.assigns.session.pid, cell.id)
end
end
@ -589,7 +592,7 @@ defmodule LivebookWeb.SessionLive do
with {:ok, cell, _section} <- Notebook.fetch_cell_and_section(data.notebook, cell_id) do
for {bound_cell, _} <- Session.Data.bound_cells_with_section(data, cell.id) do
Session.queue_cell_evaluation(socket.assigns.session_id, bound_cell.id)
Session.queue_cell_evaluation(socket.assigns.session.pid, bound_cell.id)
end
end
@ -597,38 +600,38 @@ defmodule LivebookWeb.SessionLive do
end
def handle_event("cancel_cell_evaluation", %{"cell_id" => cell_id}, socket) do
Session.cancel_cell_evaluation(socket.assigns.session_id, cell_id)
Session.cancel_cell_evaluation(socket.assigns.session.pid, cell_id)
{:noreply, socket}
end
def handle_event("save", %{}, socket) do
if socket.private.data.file do
Session.save(socket.assigns.session_id)
Session.save(socket.assigns.session.pid)
{:noreply, socket}
else
{:noreply,
push_patch(socket,
to: Routes.session_path(socket, :file_settings, socket.assigns.session_id)
to: Routes.session_path(socket, :file_settings, socket.assigns.session.id)
)}
end
end
def handle_event("show_shortcuts", %{}, socket) do
{:noreply,
push_patch(socket, to: Routes.session_path(socket, :shortcuts, socket.assigns.session_id))}
push_patch(socket, to: Routes.session_path(socket, :shortcuts, socket.assigns.session.id))}
end
def handle_event("show_runtime_settings", %{}, socket) do
{:noreply,
push_patch(socket,
to: Routes.session_path(socket, :runtime_settings, socket.assigns.session_id)
to: Routes.session_path(socket, :runtime_settings, socket.assigns.session.id)
)}
end
def handle_event("show_bin", %{}, socket) do
{:noreply,
push_patch(socket, to: Routes.session_path(socket, :bin, socket.assigns.session_id))}
push_patch(socket, to: Routes.session_path(socket, :bin, socket.assigns.session.id))}
end
def handle_event("restart_runtime", %{}, socket) do
@ -636,7 +639,7 @@ defmodule LivebookWeb.SessionLive do
if runtime = socket.private.data.runtime do
case Runtime.duplicate(runtime) do
{:ok, new_runtime} ->
Session.connect_runtime(socket.assigns.session_id, new_runtime)
Session.connect_runtime(socket.assigns.session.pid, new_runtime)
socket
{:error, message} ->
@ -694,10 +697,10 @@ defmodule LivebookWeb.SessionLive do
end
def handle_event("fork_session", %{}, socket) do
%{pid: pid, images_dir: images_dir} = socket.assigns.session
# Fetch the data, as we don't keep cells' source in the state
data = Session.get_data(socket.assigns.session_id)
data = Session.get_data(pid)
notebook = Notebook.forked(data.notebook)
%{images_dir: images_dir} = Session.get_summary(socket.assigns.session_id)
{:noreply, create_session(socket, notebook: notebook, copy_images_from: images_dir)}
end
@ -705,7 +708,7 @@ defmodule LivebookWeb.SessionLive do
Phoenix.PubSub.broadcast_from(
Livebook.PubSub,
self(),
"sessions:#{socket.assigns.session_id}",
"sessions:#{socket.assigns.session.id}",
{:location_report, self(), report}
)
@ -774,6 +777,10 @@ defmodule LivebookWeb.SessionLive do
|> assign(data_view: data_to_view(data))}
end
def handle_info({:session_updated, session}, socket) do
{:noreply, assign(socket, :session, session)}
end
def handle_info(:session_closed, socket) do
{:noreply,
socket
@ -902,22 +909,22 @@ defmodule LivebookWeb.SessionLive do
defp file_and_notebook(_fork?, _origin, notebook), do: {nil, notebook}
defp session_id_by_location(location) do
session_summaries = SessionSupervisor.get_session_summaries()
sessions = Sessions.list_sessions()
session_with_file =
Enum.find(session_summaries, fn summary ->
summary.file && {:file, summary.file} == location
Enum.find(sessions, fn session ->
session.file && {:file, session.file} == location
end)
# A session associated with the given file takes
# precedence over sessions originating from this file
if session_with_file do
{:ok, session_with_file.session_id}
{:ok, session_with_file.id}
else
session_summaries
|> Enum.filter(fn summary -> summary.origin == location end)
sessions
|> Enum.filter(fn session -> session.origin == location end)
|> case do
[summary] -> {:ok, summary.session_id}
[session] -> {:ok, session.id}
[] -> {:error, :none}
_ -> {:error, :many}
end
@ -925,7 +932,7 @@ defmodule LivebookWeb.SessionLive do
end
defp redirect_to_self(socket) do
push_patch(socket, to: Routes.session_path(socket, :page, socket.assigns.session_id))
push_patch(socket, to: Routes.session_path(socket, :page, socket.assigns.session.id))
end
defp after_operation(socket, _prev_socket, {:client_join, client_pid, user}) do
@ -978,7 +985,7 @@ defmodule LivebookWeb.SessionLive do
case type do
:input ->
push_patch(socket,
to: Routes.session_path(socket, :cell_settings, socket.assigns.session_id, cell_id)
to: Routes.session_path(socket, :cell_settings, socket.assigns.session.id, cell_id)
)
_ ->
@ -1079,7 +1086,7 @@ defmodule LivebookWeb.SessionLive do
defp insert_cell_next_to(socket, cell_id, type, idx_offset: idx_offset) do
{:ok, cell, section} = Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id)
index = Enum.find_index(section.cells, &(&1 == cell))
Session.insert_cell(socket.assigns.session_id, section.id, index + idx_offset, type)
Session.insert_cell(socket.assigns.session.pid, section.id, index + idx_offset, type)
end
defp ensure_integer(n) when is_integer(n), do: n

View file

@ -4,10 +4,10 @@ defmodule LivebookWeb.SessionLive.AttachedLive do
alias Livebook.{Session, Runtime, Utils}
@impl true
def mount(_params, %{"session_id" => session_id, "current_runtime" => current_runtime}, socket) do
def mount(_params, %{"session" => session, "current_runtime" => current_runtime}, socket) do
{:ok,
assign(socket,
session_id: session_id,
session: session,
error_message: nil,
data: initial_data(current_runtime)
)}
@ -75,7 +75,7 @@ defmodule LivebookWeb.SessionLive.AttachedLive do
case Runtime.Attached.init(node, cookie) do
{:ok, runtime} ->
Session.connect_runtime(socket.assigns.session_id, runtime)
Session.connect_runtime(socket.assigns.session.pid, runtime)
{:noreply, assign(socket, data: data, error_message: nil)}
{:error, error} ->

View file

@ -123,7 +123,7 @@ defmodule LivebookWeb.SessionLive.BinComponent do
end
def handle_event("restore", %{"cell_id" => cell_id}, socket) do
Livebook.Session.restore_cell(socket.assigns.session_id, cell_id)
Livebook.Session.restore_cell(socket.assigns.session.pid, cell_id)
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
end

View file

@ -1,7 +1,7 @@
defmodule LivebookWeb.SessionLive.CellUploadComponent do
use LivebookWeb, :live_component
alias Livebook.{Session, FileSystem}
alias Livebook.FileSystem
@impl true
def mount(socket) do
@ -71,7 +71,7 @@ defmodule LivebookWeb.SessionLive.CellUploadComponent do
end
def handle_event("save", %{"name" => name}, socket) do
%{images_dir: images_dir} = Session.get_summary(socket.assigns.session_id)
%{images_dir: images_dir} = socket.assigns.session
consume_uploaded_entries(socket, :cell_image, fn %{path: path}, entry ->
# Ensure the path is normalized (see https://github.com/elixir-plug/plug/issues/1047)

View file

@ -39,7 +39,7 @@ defmodule LivebookWeb.SessionLive.DeleteSectionComponent do
delete_cells? = delete_cells == "true"
Livebook.Session.delete_section(
socket.assigns.session_id,
socket.assigns.session.pid,
socket.assigns.section.id,
delete_cells?
)

View file

@ -44,7 +44,7 @@ defmodule LivebookWeb.SessionLive.ElixirCellSettingsComponent do
def handle_event("save", %{"disable_formatting" => disable_formatting}, socket) do
disable_formatting = disable_formatting == "true"
Session.set_cell_attributes(socket.assigns.session_id, socket.assigns.cell.id, %{
Session.set_cell_attributes(socket.assigns.session.pid, socket.assigns.cell.id, %{
disable_formatting: disable_formatting
})

View file

@ -4,13 +4,12 @@ defmodule LivebookWeb.SessionLive.ElixirStandaloneLive do
alias Livebook.{Session, Runtime}
@impl true
def mount(_params, %{"session_id" => session_id, "current_runtime" => current_runtime}, socket) do
def mount(_params, %{"session" => session, "current_runtime" => current_runtime}, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
end
{:ok,
assign(socket, session_id: session_id, current_runtime: current_runtime, error_message: nil)}
{:ok, assign(socket, session: session, current_runtime: current_runtime, error_message: nil)}
end
@impl true
@ -41,7 +40,7 @@ defmodule LivebookWeb.SessionLive.ElixirStandaloneLive do
def handle_event("init", _params, socket) do
case Runtime.ElixirStandalone.init() do
{:ok, runtime} ->
Session.connect_runtime(socket.assigns.session_id, runtime)
Session.connect_runtime(socket.assigns.session.pid, runtime)
{:noreply, assign(socket, error_message: nil)}
{:error, message} ->

View file

@ -4,8 +4,8 @@ defmodule LivebookWeb.SessionLive.EmbeddedLive do
alias Livebook.{Session, Runtime}
@impl true
def mount(_params, %{"session_id" => session_id}, socket) do
{:ok, assign(socket, session_id: session_id)}
def mount(_params, %{"session" => session}, socket) do
{:ok, assign(socket, session: session)}
end
@impl true
@ -35,7 +35,7 @@ defmodule LivebookWeb.SessionLive.EmbeddedLive do
@impl true
def handle_event("init", _params, socket) do
{:ok, runtime} = Runtime.Embedded.init()
Session.connect_runtime(socket.assigns.session_id, runtime)
Session.connect_runtime(socket.assigns.session.pid, runtime)
{:noreply, socket}
end
end

View file

@ -13,7 +13,7 @@ defmodule LivebookWeb.SessionLive.ExportComponent do
else
# Note: we need to load the notebook, because the local data
# has cell contents stripped out
notebook = Session.get_notebook(socket.assigns.session_id)
notebook = Session.get_notebook(socket.assigns.session.pid)
assign(socket, :notebook, notebook)
end
@ -32,13 +32,13 @@ defmodule LivebookWeb.SessionLive.ExportComponent do
Here you can preview and directly export the notebook source.
</p>
<div class="tabs">
<%= live_patch to: Routes.session_path(@socket, :export, @session_id, "livemd"),
<%= live_patch to: Routes.session_path(@socket, :export, @session.id, "livemd"),
class: "tab #{if(@tab == "livemd", do: "active")}" do %>
<span class="font-medium">
Live Markdown
</span>
<% end %>
<%= live_patch to: Routes.session_path(@socket, :export, @session_id, "exs"),
<%= live_patch to: Routes.session_path(@socket, :export, @session.id, "exs"),
class: "tab #{if(@tab == "exs", do: "active")}" do %>
<span class="font-medium">
Elixir Script
@ -46,11 +46,11 @@ defmodule LivebookWeb.SessionLive.ExportComponent do
<% end %>
</div>
<div>
<%= live_component component_for_tab(@tab),
id: "export-notebook-#{@tab}",
session_id: @session_id,
notebook: @notebook %>
</div>
<%= live_component component_for_tab(@tab),
id: "export-notebook-#{@tab}",
session: @session,
notebook: @notebook %>
</div>
</div>
</div>
"""

View file

@ -41,7 +41,7 @@ defmodule LivebookWeb.SessionLive.ExportElixirComponent do
</span>
<span class="tooltip left" aria-label="Download source">
<a class="icon-button"
href={Routes.session_path(@socket, :download_source, @session_id, "exs")}>
href={Routes.session_path(@socket, :download_source, @session.id, "exs")}>
<.remix_icon icon="download-2-line" class="text-lg" />
</a>
</span>

View file

@ -48,7 +48,7 @@ defmodule LivebookWeb.SessionLive.ExportLiveMarkdownComponent do
</span>
<span class="tooltip left" aria-label="Download source">
<a class="icon-button"
href={Routes.session_path(@socket, :download_source, @session_id, "livemd", include_outputs: @include_outputs)}>
href={Routes.session_path(@socket, :download_source, @session.id, "livemd", include_outputs: @include_outputs)}>
<.remix_icon icon="download-2-line" class="text-lg" />
</a>
</span>

View file

@ -136,7 +136,7 @@ defmodule LivebookWeb.SessionLive.InputCellSettingsComponent do
attrs
end
Session.set_cell_attributes(socket.assigns.session_id, socket.assigns.cell.id, attrs)
Session.set_cell_attributes(socket.assigns.session.pid, socket.assigns.cell.id, attrs)
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
end

View file

@ -6,14 +6,14 @@ defmodule LivebookWeb.SessionLive.MixStandaloneLive do
@type status :: :initial | :initializing | :finished
@impl true
def mount(_params, %{"session_id" => session_id, "current_runtime" => current_runtime}, socket) do
def mount(_params, %{"session" => session, "current_runtime" => current_runtime}, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
end
{:ok,
assign(socket,
session_id: session_id,
session: session,
status: :initial,
current_runtime: current_runtime,
file: initial_file(current_runtime),
@ -84,7 +84,7 @@ defmodule LivebookWeb.SessionLive.MixStandaloneLive do
{:noreply, add_output(socket, output)}
{:ok, runtime} ->
Session.connect_runtime(socket.assigns.session_id, runtime)
Session.connect_runtime(socket.assigns.session.pid, runtime)
{:noreply, socket |> assign(status: :finished) |> add_output("Connected successfully")}
{:error, error} ->

View file

@ -6,21 +6,21 @@ defmodule LivebookWeb.SessionLive.PersistenceLive do
# the parent live view.
use LivebookWeb, :live_view
alias Livebook.{Session, SessionSupervisor, LiveMarkdown, FileSystem}
alias Livebook.{Sessions, Session, LiveMarkdown, FileSystem}
@impl true
def mount(
_params,
%{
"session_id" => session_id,
"session" => session,
"file" => file,
"persist_outputs" => persist_outputs,
"autosave_interval_s" => autosave_interval_s
},
socket
) do
session_summaries = SessionSupervisor.get_session_summaries()
running_files = Enum.map(session_summaries, & &1.file)
sessions = Sessions.list_sessions()
running_files = Enum.map(sessions, & &1.file)
attrs = %{
file: file,
@ -30,7 +30,7 @@ defmodule LivebookWeb.SessionLive.PersistenceLive do
{:ok,
assign(socket,
session_id: session_id,
session: session,
running_files: running_files,
attrs: attrs,
new_attrs: attrs
@ -152,20 +152,20 @@ defmodule LivebookWeb.SessionLive.PersistenceLive do
autosave_interval_s = assigns.new_attrs.autosave_interval_s
if file != assigns.attrs.file do
Session.set_file(assigns.session_id, file)
Session.set_file(assigns.session.pid, file)
end
if autosave_interval_s != assigns.attrs.autosave_interval_s do
Session.set_notebook_attributes(assigns.session_id, %{
Session.set_notebook_attributes(assigns.session.pid, %{
autosave_interval_s: autosave_interval_s
})
end
Session.set_notebook_attributes(assigns.session_id, %{
Session.set_notebook_attributes(assigns.session.pid, %{
persist_outputs: assigns.new_attrs.persist_outputs
})
Session.save_sync(assigns.session_id)
Session.save_sync(assigns.session.pid)
running_files =
if file do

View file

@ -88,7 +88,7 @@ defmodule LivebookWeb.SessionLive.RuntimeComponent do
<div>
<%= live_render @socket, live_view_for_type(@type),
id: "runtime-config-#{@type}",
session: %{"session_id" => @session_id, "current_runtime" => @runtime} %>
session: %{"session" => @session, "current_runtime" => @runtime} %>
</div>
</div>
</div>
@ -116,7 +116,7 @@ defmodule LivebookWeb.SessionLive.RuntimeComponent do
end
def handle_event("disconnect", _params, socket) do
Session.disconnect_runtime(socket.assigns.session_id)
Session.disconnect_runtime(socket.assigns.session.pid)
{:noreply, socket}
end

View file

@ -1,80 +0,0 @@
defmodule Livebook.SessionSupervisorTest do
use ExUnit.Case
alias Livebook.SessionSupervisor
describe "create_session/0" do
test "creates a new session process and returns its id" do
{:ok, id} = SessionSupervisor.create_session()
{:ok, pid} = SessionSupervisor.get_session_pid(id)
assert has_child_with_pid?(SessionSupervisor, pid)
end
test "broadcasts a message" do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions")
{:ok, id} = SessionSupervisor.create_session()
assert_receive {:session_created, ^id}
end
end
describe "close_session/1" do
test "stops the session process identified by the given id" do
{:ok, id} = SessionSupervisor.create_session()
{:ok, pid} = SessionSupervisor.get_session_pid(id)
ref = Process.monitor(pid)
SessionSupervisor.close_session(id)
assert_receive {:DOWN, ^ref, :process, _, _}
refute has_child_with_pid?(SessionSupervisor, pid)
end
test "broadcasts a message" do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions")
{:ok, id} = SessionSupervisor.create_session()
SessionSupervisor.close_session(id)
assert_receive {:session_closed, ^id}
end
end
describe "get_session_ids/0" do
test "lists ids identifying sessions running under the supervisor" do
{:ok, id} = SessionSupervisor.create_session()
assert id in SessionSupervisor.get_session_ids()
end
end
describe "session_exists?/1" do
test "returns true if a session process with the given id exists" do
{:ok, id} = SessionSupervisor.create_session()
assert SessionSupervisor.session_exists?(id)
end
test "returns false if a session process with the given id does not exist" do
refute SessionSupervisor.session_exists?("nonexistent")
end
end
describe "get_session_pid/1" do
test "returns pid if a session process with the given id is running" do
{:ok, id} = SessionSupervisor.create_session()
assert {:ok, pid} = SessionSupervisor.get_session_pid(id)
assert :global.whereis_name({:session, id}) == pid
end
test "returns an error if a session process with the given id does not exist" do
assert {:error, :nonexistent} = SessionSupervisor.get_session_pid("nonexistent")
end
end
defp has_child_with_pid?(supervisor, pid) do
List.keymember?(DynamicSupervisor.which_children(supervisor), pid, 1)
end
end

View file

@ -10,101 +10,101 @@ defmodule Livebook.SessionTest do
@evaluation_wait_timeout 3_000
setup do
session_id = start_session()
%{session_id: session_id}
session = start_session()
%{session: session}
end
describe "set_notebook_attributes/2" do
test "sends an attributes update to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
test "sends an attributes update to subscribers", %{session: session} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
pid = self()
attrs = %{set_notebook_attributes: true}
Session.set_notebook_attributes(session_id, attrs)
Session.set_notebook_attributes(session.pid, attrs)
assert_receive {:operation, {:set_notebook_attributes, ^pid, ^attrs}}
end
end
describe "insert_section/2" do
test "sends an insert opreation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
test "sends an insert opreation to subscribers", %{session: session} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
pid = self()
Session.insert_section(session_id, 0)
Session.insert_section(session.pid, 0)
assert_receive {:operation, {:insert_section, ^pid, 0, _id}}
end
end
describe "insert_cell/4" do
test "sends an insert opreation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
test "sends an insert opreation to subscribers", %{session: session} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
pid = self()
Session.insert_section(session_id, 0)
Session.insert_section(session.pid, 0)
assert_receive {:operation, {:insert_section, ^pid, 0, section_id}}
Session.insert_cell(session_id, section_id, 0, :elixir)
Session.insert_cell(session.pid, section_id, 0, :elixir)
assert_receive {:operation, {:insert_cell, ^pid, ^section_id, 0, :elixir, _id}}
end
end
describe "delete_section/3" do
test "sends a delete opreation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
test "sends a delete opreation to subscribers", %{session: session} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
pid = self()
{section_id, _cell_id} = insert_section_and_cell(session_id)
{section_id, _cell_id} = insert_section_and_cell(session.pid)
Session.delete_section(session_id, section_id, false)
Session.delete_section(session.pid, section_id, false)
assert_receive {:operation, {:delete_section, ^pid, ^section_id, false}}
end
end
describe "delete_cell/2" do
test "sends a delete opreation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
test "sends a delete opreation to subscribers", %{session: session} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
pid = self()
{_section_id, cell_id} = insert_section_and_cell(session_id)
{_section_id, cell_id} = insert_section_and_cell(session.pid)
Session.delete_cell(session_id, cell_id)
Session.delete_cell(session.pid, cell_id)
assert_receive {:operation, {:delete_cell, ^pid, ^cell_id}}
end
end
describe "restore_cell/2" do
test "sends a restore opreation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
test "sends a restore opreation to subscribers", %{session: session} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
pid = self()
{_section_id, cell_id} = insert_section_and_cell(session_id)
Session.delete_cell(session_id, cell_id)
{_section_id, cell_id} = insert_section_and_cell(session.pid)
Session.delete_cell(session.pid, cell_id)
Session.restore_cell(session_id, cell_id)
Session.restore_cell(session.pid, cell_id)
assert_receive {:operation, {:restore_cell, ^pid, ^cell_id}}
end
end
describe "queue_cell_evaluation/2" do
test "sends a queue evaluation operation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
test "sends a queue evaluation operation to subscribers", %{session: session} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
pid = self()
{_section_id, cell_id} = insert_section_and_cell(session_id)
{_section_id, cell_id} = insert_section_and_cell(session.pid)
Session.queue_cell_evaluation(session_id, cell_id)
Session.queue_cell_evaluation(session.pid, cell_id)
assert_receive {:operation, {:queue_cell_evaluation, ^pid, ^cell_id}},
@evaluation_wait_timeout
end
test "triggers evaluation and sends update operation once it finishes",
%{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
%{session: session} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
{_section_id, cell_id} = insert_section_and_cell(session_id)
{_section_id, cell_id} = insert_section_and_cell(session.pid)
Session.queue_cell_evaluation(session_id, cell_id)
Session.queue_cell_evaluation(session.pid, cell_id)
assert_receive {:operation,
{:add_cell_evaluation_response, _, ^cell_id, _,
@ -114,14 +114,14 @@ defmodule Livebook.SessionTest do
end
describe "cancel_cell_evaluation/2" do
test "sends a cancel evaluation operation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
test "sends a cancel evaluation operation to subscribers", %{session: session} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
pid = self()
{_section_id, cell_id} = insert_section_and_cell(session_id)
Session.queue_cell_evaluation(session_id, cell_id)
{_section_id, cell_id} = insert_section_and_cell(session.pid)
Session.queue_cell_evaluation(session.pid, cell_id)
Session.cancel_cell_evaluation(session_id, cell_id)
Session.cancel_cell_evaluation(session.pid, cell_id)
assert_receive {:operation, {:cancel_cell_evaluation, ^pid, ^cell_id}},
@evaluation_wait_timeout
@ -129,89 +129,89 @@ defmodule Livebook.SessionTest do
end
describe "set_notebook_name/2" do
test "sends a notebook name update operation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
test "sends a notebook name update operation to subscribers", %{session: session} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
pid = self()
Session.set_notebook_name(session_id, "Cat's guide to life")
Session.set_notebook_name(session.pid, "Cat's guide to life")
assert_receive {:operation, {:set_notebook_name, ^pid, "Cat's guide to life"}}
end
end
describe "set_section_name/3" do
test "sends a section name update operation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
test "sends a section name update operation to subscribers", %{session: session} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
pid = self()
{section_id, _cell_id} = insert_section_and_cell(session_id)
{section_id, _cell_id} = insert_section_and_cell(session.pid)
Session.set_section_name(session_id, section_id, "Chapter 1")
Session.set_section_name(session.pid, section_id, "Chapter 1")
assert_receive {:operation, {:set_section_name, ^pid, ^section_id, "Chapter 1"}}
end
end
describe "apply_cell_delta/4" do
test "sends a cell delta operation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
test "sends a cell delta operation to subscribers", %{session: session} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
pid = self()
{_section_id, cell_id} = insert_section_and_cell(session_id)
{_section_id, cell_id} = insert_section_and_cell(session.pid)
delta = Delta.new() |> Delta.insert("cats")
revision = 1
Session.apply_cell_delta(session_id, cell_id, delta, revision)
Session.apply_cell_delta(session.pid, cell_id, delta, revision)
assert_receive {:operation, {:apply_cell_delta, ^pid, ^cell_id, ^delta, ^revision}}
end
end
describe "report_cell_revision/3" do
test "sends a revision report operation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
test "sends a revision report operation to subscribers", %{session: session} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
pid = self()
{_section_id, cell_id} = insert_section_and_cell(session_id)
{_section_id, cell_id} = insert_section_and_cell(session.pid)
revision = 1
Session.report_cell_revision(session_id, cell_id, revision)
Session.report_cell_revision(session.pid, cell_id, revision)
assert_receive {:operation, {:report_cell_revision, ^pid, ^cell_id, ^revision}}
end
end
describe "set_cell_attributes/3" do
test "sends an attributes update operation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
test "sends an attributes update operation to subscribers", %{session: session} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
pid = self()
{_section_id, cell_id} = insert_section_and_cell(session_id)
{_section_id, cell_id} = insert_section_and_cell(session.pid)
attrs = %{disable_formatting: true}
Session.set_cell_attributes(session_id, cell_id, attrs)
Session.set_cell_attributes(session.pid, cell_id, attrs)
assert_receive {:operation, {:set_cell_attributes, ^pid, ^cell_id, ^attrs}}
end
end
describe "connect_runtime/2" do
test "sends a runtime update operation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
test "sends a runtime update operation to subscribers", %{session: session} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
pid = self()
{:ok, runtime} = Livebook.Runtime.Embedded.init()
Session.connect_runtime(session_id, runtime)
Session.connect_runtime(session.pid, runtime)
assert_receive {:operation, {:set_runtime, ^pid, ^runtime}}
end
end
describe "disconnect_runtime/1" do
test "sends a runtime update operation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
test "sends a runtime update operation to subscribers", %{session: session} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
pid = self()
{:ok, runtime} = Livebook.Runtime.Embedded.init()
Session.connect_runtime(session_id, runtime)
Session.connect_runtime(session.pid, runtime)
Session.disconnect_runtime(session_id)
Session.disconnect_runtime(session.pid)
assert_receive {:operation, {:set_runtime, ^pid, nil}}
end
@ -220,41 +220,41 @@ defmodule Livebook.SessionTest do
describe "set_file/1" do
@tag :tmp_dir
test "sends a file update operation to subscribers",
%{session_id: session_id, tmp_dir: tmp_dir} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
%{session: session, tmp_dir: tmp_dir} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
pid = self()
tmp_dir = FileSystem.File.local(tmp_dir <> "/")
file = FileSystem.File.resolve(tmp_dir, "notebook.livemd")
Session.set_file(session_id, file)
Session.set_file(session.pid, file)
assert_receive {:operation, {:set_file, ^pid, ^file}}
end
@tag :tmp_dir
test "broadcasts an error if the path is already in use",
%{session_id: session_id, tmp_dir: tmp_dir} do
%{session: session, tmp_dir: tmp_dir} do
tmp_dir = FileSystem.File.local(tmp_dir <> "/")
file = FileSystem.File.resolve(tmp_dir, "notebook.livemd")
start_session(file: file)
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
Session.set_file(session_id, file)
Session.set_file(session.pid, file)
assert_receive {:error, "failed to set new file because it is already in use"}
end
@tag :tmp_dir
test "moves images to the new directory", %{session_id: session_id, tmp_dir: tmp_dir} do
test "moves images to the new directory", %{session: session, tmp_dir: tmp_dir} do
tmp_dir = FileSystem.File.local(tmp_dir <> "/")
%{images_dir: images_dir} = Session.get_summary(session_id)
%{images_dir: images_dir} = session
image_file = FileSystem.File.resolve(images_dir, "test.jpg")
:ok = FileSystem.File.write(image_file, "")
file = FileSystem.File.resolve(tmp_dir, "notebook.livemd")
Session.set_file(session_id, file)
Session.set_file(session.pid, file)
# Wait for the session to deal with the files
Process.sleep(50)
@ -267,23 +267,23 @@ defmodule Livebook.SessionTest do
@tag :tmp_dir
test "does not remove images from the previous dir if not temporary",
%{session_id: session_id, tmp_dir: tmp_dir} do
%{session: session, tmp_dir: tmp_dir} do
tmp_dir = FileSystem.File.local(tmp_dir <> "/")
file = FileSystem.File.resolve(tmp_dir, "notebook.livemd")
Session.set_file(session_id, file)
Session.set_file(session.pid, file)
%{images_dir: images_dir} = Session.get_summary(session_id)
%{images_dir: images_dir} = session
image_file = FileSystem.File.resolve(images_dir, "test.jpg")
:ok = FileSystem.File.write(image_file, "")
Session.set_file(session_id, nil)
Session.set_file(session.pid, nil)
# Wait for the session to deal with the files
Process.sleep(50)
assert {:ok, true} = FileSystem.File.exists?(image_file)
%{images_dir: new_images_dir} = Session.get_summary(session_id)
%{images_dir: new_images_dir} = session
assert {:ok, true} =
FileSystem.File.exists?(FileSystem.File.resolve(new_images_dir, "test.jpg"))
@ -293,36 +293,36 @@ defmodule Livebook.SessionTest do
describe "save/1" do
@tag :tmp_dir
test "persists the notebook to the associated file and notifies subscribers",
%{session_id: session_id, tmp_dir: tmp_dir} do
%{session: session, tmp_dir: tmp_dir} do
tmp_dir = FileSystem.File.local(tmp_dir <> "/")
file = FileSystem.File.resolve(tmp_dir, "notebook.livemd")
Session.set_file(session_id, file)
Session.set_file(session.pid, file)
# Perform a change, so the notebook is dirty
Session.set_notebook_name(session_id, "My notebook")
Session.set_notebook_name(session.pid, "My notebook")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
assert {:ok, false} = FileSystem.File.exists?(file)
Session.save(session_id)
Session.save(session.pid)
assert_receive {:operation, {:mark_as_not_dirty, _}}
assert {:ok, "# My notebook\n" <> _rest} = FileSystem.File.read(file)
end
@tag :tmp_dir
test "creates nonexistent directories", %{session_id: session_id, tmp_dir: tmp_dir} do
test "creates nonexistent directories", %{session: session, tmp_dir: tmp_dir} do
tmp_dir = FileSystem.File.local(tmp_dir <> "/")
file = FileSystem.File.resolve(tmp_dir, "nonexistent/dir/notebook.livemd")
Session.set_file(session_id, file)
Session.set_file(session.pid, file)
# Perform a change, so the notebook is dirty
Session.set_notebook_name(session_id, "My notebook")
Session.set_notebook_name(session.pid, "My notebook")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
assert {:ok, false} = FileSystem.File.exists?(file)
Session.save(session_id)
Session.save(session.pid)
assert_receive {:operation, {:mark_as_not_dirty, _}}
assert {:ok, "# My notebook\n" <> _rest} = FileSystem.File.read(file)
@ -332,32 +332,32 @@ defmodule Livebook.SessionTest do
describe "close/1" do
@tag :tmp_dir
test "saves the notebook and notifies subscribers once the session is closed",
%{session_id: session_id, tmp_dir: tmp_dir} do
%{session: session, tmp_dir: tmp_dir} do
tmp_dir = FileSystem.File.local(tmp_dir <> "/")
file = FileSystem.File.resolve(tmp_dir, "notebook.livemd")
Session.set_file(session_id, file)
Session.set_file(session.pid, file)
# Perform a change, so the notebook is dirty
Session.set_notebook_name(session_id, "My notebook")
Session.set_notebook_name(session.pid, "My notebook")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
assert {:ok, false} = FileSystem.File.exists?(file)
Process.flag(:trap_exit, true)
Session.close(session_id)
Session.close(session.pid)
assert_receive :session_closed
assert {:ok, "# My notebook\n" <> _rest} = FileSystem.File.read(file)
end
test "clears session temporary directory", %{session_id: session_id} do
%{images_dir: images_dir} = Session.get_summary(session_id)
test "clears session temporary directory", %{session: session} do
%{images_dir: images_dir} = session
:ok = FileSystem.File.create_dir(images_dir)
assert {:ok, true} = FileSystem.File.exists?(images_dir)
Process.flag(:trap_exit, true)
Session.close(session_id)
Session.close(session.pid)
# Wait for the session to deal with the files
Process.sleep(50)
@ -384,9 +384,8 @@ defmodule Livebook.SessionTest do
image_file = FileSystem.File.resolve(tmp_dir, "image.jpg")
:ok = FileSystem.File.write(image_file, "")
session_id = start_session(copy_images_from: tmp_dir)
%{images_dir: images_dir} = Session.get_summary(session_id)
session = start_session(copy_images_from: tmp_dir)
%{images_dir: images_dir} = session
assert {:ok, true} =
FileSystem.File.exists?(FileSystem.File.resolve(images_dir, "image.jpg"))
@ -395,9 +394,8 @@ defmodule Livebook.SessionTest do
test "saves images when :images option is specified" do
images = %{"image.jpg" => "binary content"}
session_id = start_session(images: images)
%{images_dir: images_dir} = Session.get_summary(session_id)
session = start_session(images: images)
%{images_dir: images_dir} = session
assert FileSystem.File.resolve(images_dir, "image.jpg") |> FileSystem.File.read() ==
{:ok, "binary content"}
@ -410,14 +408,13 @@ defmodule Livebook.SessionTest do
# to verify session integrates well with it properly.
test "starts a standalone runtime upon first evaluation if there was none set explicitly" do
session_id = Utils.random_id()
{:ok, _} = Session.start_link(id: session_id)
session = start_session()
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
{_section_id, cell_id} = insert_section_and_cell(session_id)
{_section_id, cell_id} = insert_section_and_cell(session.pid)
Session.queue_cell_evaluation(session_id, cell_id)
Session.queue_cell_evaluation(session.pid, cell_id)
# Give it a bit more time as this involves starting a system process.
assert_receive {:operation,
{:add_cell_evaluation_response, _, ^cell_id, _,
@ -426,14 +423,13 @@ defmodule Livebook.SessionTest do
end
test "if the runtime node goes down, notifies the subscribers" do
session_id = Utils.random_id()
{:ok, _} = Session.start_link(id: session_id)
session = start_session()
{:ok, runtime} = Runtime.ElixirStandalone.init()
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
# Wait for the runtime to be set
Session.connect_runtime(session_id, runtime)
Session.connect_runtime(session.pid, runtime)
assert_receive {:operation, {:set_runtime, _, ^runtime}}
# Terminate the other node, the session should detect that
@ -443,11 +439,11 @@ defmodule Livebook.SessionTest do
assert_receive {:info, "runtime node terminated unexpectedly"}
end
test "on user change sends an update operation subscribers", %{session_id: session_id} do
test "on user change sends an update operation subscribers", %{session: session} do
user = Livebook.Users.User.new()
Session.register_client(session_id, self(), user)
Session.register_client(session.pid, self(), user)
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
updated_user = %{user | name: "Jake Peralta"}
Livebook.Users.broadcast_change(updated_user)
@ -475,12 +471,12 @@ defmodule Livebook.SessionTest do
]
}
session_id = start_session(notebook: notebook)
session = start_session(notebook: notebook)
cell_id = elixir_cell.id
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Session.queue_cell_evaluation(session_id, cell_id)
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
Session.queue_cell_evaluation(session.pid, cell_id)
assert_receive {:operation,
{:add_cell_evaluation_response, _, ^cell_id, {:text, text_output},
@ -505,12 +501,12 @@ defmodule Livebook.SessionTest do
]
}
session_id = start_session(notebook: notebook)
session = start_session(notebook: notebook)
cell_id = elixir_cell.id
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Session.queue_cell_evaluation(session_id, cell_id)
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
Session.queue_cell_evaluation(session.pid, cell_id)
assert_receive {:operation,
{:add_cell_evaluation_response, _, ^cell_id, {:text, text_output},
@ -537,12 +533,12 @@ defmodule Livebook.SessionTest do
]
}
session_id = start_session(notebook: notebook)
session = start_session(notebook: notebook)
cell_id = elixir_cell.id
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Session.queue_cell_evaluation(session_id, cell_id)
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
Session.queue_cell_evaluation(session.pid, cell_id)
assert_receive {:operation,
{:add_cell_evaluation_response, _, ^cell_id, {:text, text_output},
@ -555,14 +551,14 @@ defmodule Livebook.SessionTest do
defp start_session(opts \\ []) do
session_id = Utils.random_id()
{:ok, _} = Session.start_link(Keyword.merge(opts, id: session_id))
session_id
{:ok, pid} = Session.start_link(Keyword.merge([id: session_id], opts))
Session.get_by_pid(pid)
end
defp insert_section_and_cell(session_id) do
Session.insert_section(session_id, 0)
defp insert_section_and_cell(session_pid) do
Session.insert_section(session_pid, 0)
assert_receive {:operation, {:insert_section, _, 0, section_id}}
Session.insert_cell(session_id, section_id, 0, :elixir)
Session.insert_cell(session_pid, section_id, 0, :elixir)
assert_receive {:operation, {:insert_cell, _, ^section_id, 0, :elixir, cell_id}}
{section_id, cell_id}

View file

@ -0,0 +1,51 @@
defmodule Livebook.SessionsTest do
use ExUnit.Case
alias Livebook.Sessions
describe "create_session/0" do
test "starts a new session process under the sessions supervisor" do
{:ok, session} = Sessions.create_session()
assert has_child_with_pid?(Livebook.SessionSupervisor, session.pid)
end
test "broadcasts a message to subscribers" do
Phoenix.PubSub.subscribe(Livebook.PubSub, "tracker_sessions")
{:ok, %{id: id}} = Sessions.create_session()
assert_receive {:session_created, %{id: ^id}}
end
end
describe "list_sessions/0" do
test "lists all sessions" do
{:ok, session} = Sessions.create_session()
assert session in Sessions.list_sessions()
end
end
describe "fetch_session/1" do
test "returns an error if no session with the given id exists" do
id = Livebook.Utils.random_node_aware_id()
assert :error = Sessions.fetch_session(id)
end
test "returns session matching the given id" do
{:ok, session} = Sessions.create_session()
assert {:ok, ^session} = Sessions.fetch_session(session.id)
end
end
describe "update_session/1" do
test "broadcasts a message to subscribers" do
{:ok, %{id: id} = session} = Sessions.create_session()
Phoenix.PubSub.subscribe(Livebook.PubSub, "tracker_sessions")
updated_session = %{session | notebook_name: "New name"}
Livebook.Sessions.update_session(updated_session)
assert_receive {:session_updated, %{id: ^id, notebook_name: "New name"}}
end
end
defp has_child_with_pid?(supervisor, pid) do
List.keymember?(DynamicSupervisor.which_children(supervisor), pid, 1)
end
end

View file

@ -1,62 +1,64 @@
defmodule LivebookWeb.SessionControllerTest do
use LivebookWeb.ConnCase, async: true
alias Livebook.{SessionSupervisor, Session, Notebook, FileSystem}
alias Livebook.{Sessions, Session, Notebook, FileSystem}
describe "show_image" do
test "returns not found when the given session does not exist", %{conn: conn} do
conn = get(conn, Routes.session_path(conn, :show_image, "nonexistent", "image.jpg"))
id = Livebook.Utils.random_node_aware_id()
conn = get(conn, Routes.session_path(conn, :show_image, id, "image.jpg"))
assert conn.status == 404
assert conn.resp_body == "Not found"
end
test "returns not found when the given image does not exist", %{conn: conn} do
{:ok, session_id} = SessionSupervisor.create_session()
{:ok, session} = Sessions.create_session()
conn = get(conn, Routes.session_path(conn, :show_image, session_id, "nonexistent.jpg"))
conn = get(conn, Routes.session_path(conn, :show_image, session.id, "nonexistent.jpg"))
assert conn.status == 404
assert conn.resp_body == "No such file or directory"
SessionSupervisor.close_session(session_id)
Session.close(session.pid)
end
test "returns the image when it does exist", %{conn: conn} do
{:ok, session_id} = SessionSupervisor.create_session()
%{images_dir: images_dir} = Session.get_summary(session_id)
{:ok, session} = Sessions.create_session()
%{images_dir: images_dir} = session
:ok = FileSystem.File.resolve(images_dir, "test.jpg") |> FileSystem.File.write("")
conn = get(conn, Routes.session_path(conn, :show_image, session_id, "test.jpg"))
conn = get(conn, Routes.session_path(conn, :show_image, session.id, "test.jpg"))
assert conn.status == 200
assert get_resp_header(conn, "content-type") == ["image/jpeg"]
SessionSupervisor.close_session(session_id)
Session.close(session.pid)
end
end
describe "download_source" do
test "returns not found when the given session does not exist", %{conn: conn} do
conn = get(conn, Routes.session_path(conn, :download_source, "nonexistent", "livemd"))
id = Livebook.Utils.random_node_aware_id()
conn = get(conn, Routes.session_path(conn, :download_source, id, "livemd"))
assert conn.status == 404
assert conn.resp_body == "Not found"
end
test "returns bad request when given an invalid format", %{conn: conn} do
{:ok, session_id} = SessionSupervisor.create_session()
{:ok, session} = Sessions.create_session()
conn = get(conn, Routes.session_path(conn, :download_source, session_id, "invalid"))
conn = get(conn, Routes.session_path(conn, :download_source, session.id, "invalid"))
assert conn.status == 400
assert conn.resp_body == "Invalid format, supported formats: livemd, exs"
end
test "handles live markdown notebook source", %{conn: conn} do
{:ok, session_id} = SessionSupervisor.create_session()
{:ok, session} = Sessions.create_session()
conn = get(conn, Routes.session_path(conn, :download_source, session_id, "livemd"))
conn = get(conn, Routes.session_path(conn, :download_source, session.id, "livemd"))
assert conn.status == 200
assert get_resp_header(conn, "content-type") == ["text/plain"]
@ -71,7 +73,7 @@ defmodule LivebookWeb.SessionControllerTest do
```
"""
SessionSupervisor.close_session(session_id)
Session.close(session.pid)
end
test "includes output in markdown when include_outputs parameter is set", %{conn: conn} do
@ -95,10 +97,10 @@ defmodule LivebookWeb.SessionControllerTest do
]
}
{:ok, session_id} = SessionSupervisor.create_session(notebook: notebook)
{:ok, session} = Sessions.create_session(notebook: notebook)
query = [include_outputs: "true"]
conn = get(conn, Routes.session_path(conn, :download_source, session_id, "livemd", query))
conn = get(conn, Routes.session_path(conn, :download_source, session.id, "livemd", query))
assert conn.status == 200
assert get_resp_header(conn, "content-type") == ["text/plain"]
@ -117,13 +119,13 @@ defmodule LivebookWeb.SessionControllerTest do
```
"""
SessionSupervisor.close_session(session_id)
Session.close(session.pid)
end
test "handles elixir notebook source", %{conn: conn} do
{:ok, session_id} = SessionSupervisor.create_session()
{:ok, session} = Sessions.create_session()
conn = get(conn, Routes.session_path(conn, :download_source, session_id, "exs"))
conn = get(conn, Routes.session_path(conn, :download_source, session.id, "exs"))
assert conn.status == 200
assert get_resp_header(conn, "content-type") == ["text/plain"]
@ -134,7 +136,7 @@ defmodule LivebookWeb.SessionControllerTest do
# ── Section ──
"""
SessionSupervisor.close_session(session_id)
Session.close(session.pid)
end
end
end

View file

@ -3,7 +3,7 @@ defmodule LivebookWeb.HomeLiveTest do
import Phoenix.LiveViewTest
alias Livebook.{SessionSupervisor, Session}
alias Livebook.{Sessions, Session}
test "disconnected and connected render", %{conn: conn} do
{:ok, view, disconnected_html} = live(conn, "/")
@ -113,34 +113,38 @@ defmodule LivebookWeb.HomeLiveTest do
describe "sessions list" do
test "lists running sessions", %{conn: conn} do
{:ok, id1} = SessionSupervisor.create_session()
{:ok, id2} = SessionSupervisor.create_session()
{:ok, session1} = Sessions.create_session()
{:ok, session2} = Sessions.create_session()
{:ok, view, _} = live(conn, "/")
assert render(view) =~ id1
assert render(view) =~ id2
assert render(view) =~ session1.id
assert render(view) =~ session2.id
end
test "updates UI whenever a session is added or deleted", %{conn: conn} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "tracker_sessions")
{:ok, view, _} = live(conn, "/")
{:ok, id} = SessionSupervisor.create_session()
{:ok, %{id: id} = session} = Sessions.create_session()
assert_receive {:session_created, %{id: ^id}}
assert render(view) =~ id
SessionSupervisor.close_session(id)
Session.close(session.pid)
assert_receive {:session_closed, %{id: ^id}}
refute render(view) =~ id
end
test "allows forking existing session", %{conn: conn} do
{:ok, id} = SessionSupervisor.create_session()
Session.set_notebook_name(id, "My notebook")
{:ok, session} = Sessions.create_session()
Session.set_notebook_name(session.pid, "My notebook")
{:ok, view, _} = live(conn, "/")
assert {:error, {:live_redirect, %{to: to}}} =
view
|> element(~s{[data-test-session-id="#{id}"] button}, "Fork")
|> element(~s{[data-test-session-id="#{session.id}"] button}, "Fork")
|> render_click()
assert to =~ "/sessions/"
@ -150,21 +154,21 @@ defmodule LivebookWeb.HomeLiveTest do
end
test "allows closing session after confirmation", %{conn: conn} do
{:ok, id} = SessionSupervisor.create_session()
{:ok, session} = Sessions.create_session()
{:ok, view, _} = live(conn, "/")
assert render(view) =~ id
assert render(view) =~ session.id
view
|> element(~s{[data-test-session-id="#{id}"] a}, "Close")
|> element(~s{[data-test-session-id="#{session.id}"] a}, "Close")
|> render_click()
view
|> element(~s{button}, "Close session")
|> render_click()
refute render(view) =~ id
refute render(view) =~ session.id
end
end
@ -185,7 +189,7 @@ defmodule LivebookWeb.HomeLiveTest do
describe "notebook import" do
test "allows importing notebook directly from content", %{conn: conn} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions")
Phoenix.PubSub.subscribe(Livebook.PubSub, "tracker_sessions")
{:ok, view, _} = live(conn, "/home/import/content")
@ -197,7 +201,7 @@ defmodule LivebookWeb.HomeLiveTest do
|> element("form", "Import")
|> render_submit(%{data: %{content: notebook_content}})
assert_receive {:session_created, id}
assert_receive {:session_created, %{id: id}}
{:ok, view, _} = live(conn, "/sessions/#{id}")
assert render(view) =~ "My notebook"

View file

@ -3,116 +3,116 @@ defmodule LivebookWeb.SessionLiveTest do
import Phoenix.LiveViewTest
alias Livebook.{SessionSupervisor, Session, Delta, Runtime, Users, FileSystem}
alias Livebook.{Sessions, Session, Delta, Runtime, Users, FileSystem}
alias Livebook.Notebook.Cell
alias Livebook.Users.User
setup do
{:ok, session_id} = SessionSupervisor.create_session(notebook: Livebook.Notebook.new())
%{session_id: session_id}
{:ok, session} = Sessions.create_session(notebook: Livebook.Notebook.new())
%{session: session}
end
test "disconnected and connected render", %{conn: conn, session_id: session_id} do
{:ok, view, disconnected_html} = live(conn, "/sessions/#{session_id}")
test "disconnected and connected render", %{conn: conn, session: session} do
{:ok, view, disconnected_html} = live(conn, "/sessions/#{session.id}")
assert disconnected_html =~ "Untitled notebook"
assert render(view) =~ "Untitled notebook"
end
describe "asynchronous updates" do
test "renders an updated notebook name", %{conn: conn, session_id: session_id} do
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
test "renders an updated notebook name", %{conn: conn, session: session} do
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
Session.set_notebook_name(session_id, "My notebook")
wait_for_session_update(session_id)
Session.set_notebook_name(session.pid, "My notebook")
wait_for_session_update(session.pid)
assert render(view) =~ "My notebook"
end
test "renders a newly inserted section", %{conn: conn, session_id: session_id} do
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
test "renders a newly inserted section", %{conn: conn, session: session} do
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
section_id = insert_section(session_id)
section_id = insert_section(session.pid)
assert render(view) =~ section_id
end
test "renders an updated section name", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
test "renders an updated section name", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
Session.set_section_name(session_id, section_id, "My section")
wait_for_session_update(session_id)
Session.set_section_name(session.pid, section_id, "My section")
wait_for_session_update(session.pid)
assert render(view) =~ "My section"
end
test "renders a newly inserted cell", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
test "renders a newly inserted cell", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
cell_id = insert_text_cell(session_id, section_id, :markdown)
cell_id = insert_text_cell(session.pid, section_id, :markdown)
assert render(view) =~ "cell-" <> cell_id
end
test "un-renders a deleted cell", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_text_cell(session_id, section_id, :markdown)
test "un-renders a deleted cell", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_text_cell(session.pid, section_id, :markdown)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
Session.delete_cell(session_id, cell_id)
wait_for_session_update(session_id)
Session.delete_cell(session.pid, cell_id)
wait_for_session_update(session.pid)
refute render(view) =~ "cell-" <> cell_id
end
end
describe "UI events update the shared state" do
test "adding a new section", %{conn: conn, session_id: session_id} do
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
test "adding a new section", %{conn: conn, session: session} do
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element("button", "New section")
|> render_click()
assert %{notebook: %{sections: [_section]}} = Session.get_data(session_id)
assert %{notebook: %{sections: [_section]}} = Session.get_data(session.pid)
end
test "adding a new cell", %{conn: conn, session_id: session_id} do
Session.insert_section(session_id, 0)
test "adding a new cell", %{conn: conn, session: session} do
Session.insert_section(session.pid, 0)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element("button", "+ Markdown")
|> render_click()
assert %{notebook: %{sections: [%{cells: [%Cell.Markdown{}]}]}} =
Session.get_data(session_id)
Session.get_data(session.pid)
end
test "queueing cell evaluation", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_text_cell(session_id, section_id, :elixir, "Process.sleep(10)")
test "queueing cell evaluation", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_text_cell(session.pid, section_id, :elixir, "Process.sleep(10)")
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element("#session")
|> render_hook("queue_cell_evaluation", %{"cell_id" => cell_id})
assert %{cell_infos: %{^cell_id => %{evaluation_status: :evaluating}}} =
Session.get_data(session_id)
Session.get_data(session.pid)
end
test "cancelling cell evaluation", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_text_cell(session_id, section_id, :elixir, "Process.sleep(2000)")
test "cancelling cell evaluation", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_text_cell(session.pid, section_id, :elixir, "Process.sleep(2000)")
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element("#session")
@ -123,71 +123,71 @@ defmodule LivebookWeb.SessionLiveTest do
|> render_hook("cancel_cell_evaluation", %{"cell_id" => cell_id})
assert %{cell_infos: %{^cell_id => %{evaluation_status: :ready}}} =
Session.get_data(session_id)
Session.get_data(session.pid)
end
test "inserting a cell below the given cell", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_text_cell(session_id, section_id, :elixir)
test "inserting a cell below the given cell", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_text_cell(session.pid, section_id, :elixir)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element("#session")
|> render_hook("insert_cell_below", %{"cell_id" => cell_id, "type" => "markdown"})
assert %{notebook: %{sections: [%{cells: [_first_cell, %Cell.Markdown{}]}]}} =
Session.get_data(session_id)
Session.get_data(session.pid)
end
test "inserting a cell above the given cell", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_text_cell(session_id, section_id, :elixir)
test "inserting a cell above the given cell", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_text_cell(session.pid, section_id, :elixir)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element("#session")
|> render_hook("insert_cell_above", %{"cell_id" => cell_id, "type" => "markdown"})
assert %{notebook: %{sections: [%{cells: [%Cell.Markdown{}, _first_cell]}]}} =
Session.get_data(session_id)
Session.get_data(session.pid)
end
test "deleting the given cell", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_text_cell(session_id, section_id, :elixir)
test "deleting the given cell", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_text_cell(session.pid, section_id, :elixir)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element("#session")
|> render_hook("delete_cell", %{"cell_id" => cell_id})
assert %{notebook: %{sections: [%{cells: []}]}} = Session.get_data(session_id)
assert %{notebook: %{sections: [%{cells: []}]}} = Session.get_data(session.pid)
end
test "newlines in input values are normalized", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_input_cell(session_id, section_id)
test "newlines in input values are normalized", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_input_cell(session.pid, section_id)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element(~s/form[phx-change="set_cell_value"]/)
|> render_change(%{"value" => "line\r\nline"})
assert %{notebook: %{sections: [%{cells: [%{id: ^cell_id, value: "line\nline"}]}]}} =
Session.get_data(session_id)
Session.get_data(session.pid)
end
end
describe "runtime settings" do
test "connecting to elixir standalone updates connect button to reconnect",
%{conn: conn, session_id: session_id} do
{:ok, view, _} = live(conn, "/sessions/#{session_id}/settings/runtime")
%{conn: conn, session: session} do
{:ok, view, _} = live(conn, "/sessions/#{session.id}/settings/runtime")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
[elixir_standalone_view] = live_children(view)
@ -207,8 +207,8 @@ defmodule LivebookWeb.SessionLiveTest do
describe "persistence settings" do
@tag :tmp_dir
test "saving to file shows the newly created file",
%{conn: conn, session_id: session_id, tmp_dir: tmp_dir} do
{:ok, view, _} = live(conn, "/sessions/#{session_id}/settings/file")
%{conn: conn, session: session, tmp_dir: tmp_dir} do
{:ok, view, _} = live(conn, "/sessions/#{session.id}/settings/file")
assert view = find_live_child(view, "persistence")
@ -233,8 +233,8 @@ defmodule LivebookWeb.SessionLiveTest do
@tag :tmp_dir
test "changing output persistence updates data",
%{conn: conn, session_id: session_id, tmp_dir: tmp_dir} do
{:ok, view, _} = live(conn, "/sessions/#{session_id}/settings/file")
%{conn: conn, session: session, tmp_dir: tmp_dir} do
{:ok, view, _} = live(conn, "/sessions/#{session.id}/settings/file")
assert view = find_live_child(view, "persistence")
@ -256,17 +256,17 @@ defmodule LivebookWeb.SessionLiveTest do
|> element(~s{button[phx-click="save"]}, "Save")
|> render_click()
assert %{notebook: %{persist_outputs: true}} = Session.get_data(session_id)
assert %{notebook: %{persist_outputs: true}} = Session.get_data(session.pid)
end
end
describe "completion" do
test "replies with nil completion reference when no runtime is started",
%{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_text_cell(session_id, section_id, :elixir, "Process.sleep(10)")
%{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_text_cell(session.pid, section_id, :elixir, "Process.sleep(10)")
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element("#session")
@ -280,14 +280,14 @@ defmodule LivebookWeb.SessionLiveTest do
end
test "replies with completion reference and then sends asynchronous response",
%{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_text_cell(session_id, section_id, :elixir, "Process.sleep(10)")
%{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_text_cell(session.pid, section_id, :elixir, "Process.sleep(10)")
{:ok, runtime} = Livebook.Runtime.Embedded.init()
Session.connect_runtime(session_id, runtime)
Session.connect_runtime(session.pid, runtime)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element("#session")
@ -307,11 +307,11 @@ defmodule LivebookWeb.SessionLiveTest do
end
end
test "forking the session", %{conn: conn, session_id: session_id} do
Session.set_notebook_name(session_id, "My notebook")
wait_for_session_update(session_id)
test "forking the session", %{conn: conn, session: session} do
Session.set_notebook_name(session.pid, "My notebook")
wait_for_session_update(session.pid)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
assert {:error, {:live_redirect, %{to: to}}} =
view
@ -325,19 +325,19 @@ defmodule LivebookWeb.SessionLiveTest do
end
describe "connected users" do
test "lists connected users", %{conn: conn, session_id: session_id} do
test "lists connected users", %{conn: conn, session: session} do
user1 = create_user_with_name("Jake Peralta")
client_pid =
spawn_link(fn ->
Session.register_client(session_id, self(), user1)
Session.register_client(session.pid, self(), user1)
receive do
:stop -> :ok
end
end)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
assert render(view) =~ "Jake Peralta"
@ -345,16 +345,16 @@ defmodule LivebookWeb.SessionLiveTest do
end
test "updates users list whenever a user joins or leaves",
%{conn: conn, session_id: session_id} do
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
%{conn: conn, session: session} do
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
user1 = create_user_with_name("Jake Peralta")
client_pid =
spawn_link(fn ->
Session.register_client(session_id, self(), user1)
Session.register_client(session.pid, self(), user1)
receive do
:stop -> :ok
@ -370,21 +370,21 @@ defmodule LivebookWeb.SessionLiveTest do
end
test "updates users list whenever a user changes his data",
%{conn: conn, session_id: session_id} do
%{conn: conn, session: session} do
user1 = create_user_with_name("Jake Peralta")
client_pid =
spawn_link(fn ->
Session.register_client(session_id, self(), user1)
Session.register_client(session.pid, self(), user1)
receive do
:stop -> :ok
end
end)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
assert render(view) =~ "Jake Peralta"
@ -399,11 +399,11 @@ defmodule LivebookWeb.SessionLiveTest do
end
describe "input cell settings" do
test "setting input cell attributes updates data", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_input_cell(session_id, section_id)
test "setting input cell attributes updates data", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_input_cell(session.pid, section_id)
{:ok, view, _} = live(conn, "/sessions/#{session_id}/cell-settings/#{cell_id}")
{:ok, view, _} = live(conn, "/sessions/#{session.id}/cell-settings/#{cell_id}")
form_selector = ~s/[role="dialog"] form/
@ -439,28 +439,28 @@ defmodule LivebookWeb.SessionLiveTest do
}
]
}
} = Session.get_data(session_id)
} = Session.get_data(session.pid)
end
end
describe "relative paths" do
test "renders an info message when the path doesn't have notebook extension",
%{conn: conn, session_id: session_id} do
session_path = "/sessions/#{session_id}"
%{conn: conn, session: session} do
session_path = "/sessions/#{session.id}"
assert {:error, {:live_redirect, %{to: ^session_path}}} =
result = live(conn, "/sessions/#{session_id}/document.pdf")
result = live(conn, "/sessions/#{session.id}/document.pdf")
{:ok, view, _} = follow_redirect(result, conn)
assert render(view) =~ "Got unrecognised session path: document.pdf"
end
test "renders an info message when the session has neither original url nor path",
%{conn: conn, session_id: session_id} do
session_path = "/sessions/#{session_id}"
%{conn: conn, session: session} do
session_path = "/sessions/#{session.id}"
assert {:error, {:live_redirect, %{to: ^session_path}}} =
result = live(conn, "/sessions/#{session_id}/notebook.livemd")
result = live(conn, "/sessions/#{session.id}/notebook.livemd")
{:ok, view, _} = follow_redirect(result, conn)
@ -470,18 +470,18 @@ defmodule LivebookWeb.SessionLiveTest do
@tag :tmp_dir
test "renders an error message when the relative notebook does not exist",
%{conn: conn, session_id: session_id, tmp_dir: tmp_dir} do
%{conn: conn, session: session, tmp_dir: tmp_dir} do
tmp_dir = FileSystem.File.local(tmp_dir <> "/")
index_file = FileSystem.File.resolve(tmp_dir, "index.livemd")
notebook_file = FileSystem.File.resolve(tmp_dir, "notebook.livemd")
Session.set_file(session_id, index_file)
wait_for_session_update(session_id)
Session.set_file(session.pid, index_file)
wait_for_session_update(session.pid)
session_path = "/sessions/#{session_id}"
session_path = "/sessions/#{session.id}"
assert {:error, {:live_redirect, %{to: ^session_path}}} =
result = live(conn, "/sessions/#{session_id}/notebook.livemd")
result = live(conn, "/sessions/#{session.id}/notebook.livemd")
{:ok, view, _} = follow_redirect(result, conn)
@ -491,24 +491,25 @@ defmodule LivebookWeb.SessionLiveTest do
@tag :tmp_dir
test "opens a relative notebook if it exists",
%{conn: conn, session_id: session_id, tmp_dir: tmp_dir} do
%{conn: conn, session: session, tmp_dir: tmp_dir} do
tmp_dir = FileSystem.File.local(tmp_dir <> "/")
index_file = FileSystem.File.resolve(tmp_dir, "index.livemd")
notebook_file = FileSystem.File.resolve(tmp_dir, "notebook.livemd")
Session.set_file(session_id, index_file)
wait_for_session_update(session_id)
Session.set_file(session.pid, index_file)
wait_for_session_update(session.pid)
:ok = FileSystem.File.write(notebook_file, "# Sibling notebook")
assert {:error, {:live_redirect, %{to: new_session_path}}} =
result = live(conn, "/sessions/#{session_id}/notebook.livemd")
result = live(conn, "/sessions/#{session.id}/notebook.livemd")
{:ok, view, _} = follow_redirect(result, conn)
assert render(view) =~ "Sibling notebook"
"/sessions/" <> session_id = new_session_path
data = Session.get_data(session_id)
{:ok, session} = Sessions.fetch_session(session_id)
data = Session.get_data(session.pid)
assert data.file == notebook_file
end
@ -519,76 +520,77 @@ defmodule LivebookWeb.SessionLiveTest do
index_file = FileSystem.File.resolve(tmp_dir, "index.livemd")
notebook_file = FileSystem.File.resolve(tmp_dir, "notebook.livemd")
{:ok, session_id} = SessionSupervisor.create_session(origin: {:file, index_file})
{:ok, session} = Sessions.create_session(origin: {:file, index_file})
:ok = FileSystem.File.write(notebook_file, "# Sibling notebook")
assert {:error, {:live_redirect, %{to: new_session_path}}} =
result = live(conn, "/sessions/#{session_id}/notebook.livemd")
result = live(conn, "/sessions/#{session.id}/notebook.livemd")
{:ok, view, _} = follow_redirect(result, conn)
assert render(view) =~ "Sibling notebook - fork"
"/sessions/" <> session_id = new_session_path
data = Session.get_data(session_id)
{:ok, session} = Sessions.fetch_session(session_id)
data = Session.get_data(session.pid)
assert data.file == nil
assert data.origin == {:file, notebook_file}
end
@tag :tmp_dir
test "if the notebook is already open, redirects to the session",
%{conn: conn, session_id: session_id, tmp_dir: tmp_dir} do
%{conn: conn, session: session, tmp_dir: tmp_dir} do
tmp_dir = FileSystem.File.local(tmp_dir <> "/")
index_file = FileSystem.File.resolve(tmp_dir, "index.livemd")
notebook_file = FileSystem.File.resolve(tmp_dir, "notebook.livemd")
Session.set_file(session_id, index_file)
wait_for_session_update(session_id)
Session.set_file(session.pid, index_file)
wait_for_session_update(session.pid)
:ok = FileSystem.File.write(notebook_file, "# Sibling notebook")
assert {:error, {:live_redirect, %{to: session_path}}} =
live(conn, "/sessions/#{session_id}/notebook.livemd")
live(conn, "/sessions/#{session.id}/notebook.livemd")
assert {:error, {:live_redirect, %{to: ^session_path}}} =
live(conn, "/sessions/#{session_id}/notebook.livemd")
live(conn, "/sessions/#{session.id}/notebook.livemd")
end
@tag :tmp_dir
test "handles nested paths", %{conn: conn, session_id: session_id, tmp_dir: tmp_dir} do
test "handles nested paths", %{conn: conn, session: session, tmp_dir: tmp_dir} do
tmp_dir = FileSystem.File.local(tmp_dir <> "/")
parent_file = FileSystem.File.resolve(tmp_dir, "parent.livemd")
child_dir = FileSystem.File.resolve(tmp_dir, "dir/")
child_file = FileSystem.File.resolve(child_dir, "child.livemd")
Session.set_file(session_id, parent_file)
wait_for_session_update(session_id)
Session.set_file(session.pid, parent_file)
wait_for_session_update(session.pid)
:ok = FileSystem.File.write(child_file, "# Child notebook")
{:ok, view, _} =
conn
|> live("/sessions/#{session_id}/dir/child.livemd")
|> live("/sessions/#{session.id}/dir/child.livemd")
|> follow_redirect(conn)
assert render(view) =~ "Child notebook"
end
@tag :tmp_dir
test "handles parent paths", %{conn: conn, session_id: session_id, tmp_dir: tmp_dir} do
test "handles parent paths", %{conn: conn, session: session, tmp_dir: tmp_dir} do
tmp_dir = FileSystem.File.local(tmp_dir <> "/")
parent_file = FileSystem.File.resolve(tmp_dir, "parent.livemd")
child_dir = FileSystem.File.resolve(tmp_dir, "dir/")
child_file = FileSystem.File.resolve(child_dir, "child.livemd")
Session.set_file(session_id, child_file)
wait_for_session_update(session_id)
Session.set_file(session.pid, child_file)
wait_for_session_update(session.pid)
:ok = FileSystem.File.write(parent_file, "# Parent notebook")
{:ok, view, _} =
conn
|> live("/sessions/#{session_id}/__parent__/parent.livemd")
|> live("/sessions/#{session.id}/__parent__/parent.livemd")
|> follow_redirect(conn)
assert render(view) =~ "Parent notebook"
@ -604,11 +606,11 @@ defmodule LivebookWeb.SessionLiveTest do
end)
index_url = url(bypass.port) <> "/index.livemd"
{:ok, session_id} = SessionSupervisor.create_session(origin: {:url, index_url})
{:ok, session} = Sessions.create_session(origin: {:url, index_url})
{:ok, view, _} =
conn
|> live("/sessions/#{session_id}/notebook.livemd")
|> live("/sessions/#{session.id}/notebook.livemd")
|> follow_redirect(conn)
assert render(view) =~ "My notebook"
@ -623,12 +625,12 @@ defmodule LivebookWeb.SessionLiveTest do
index_url = url(bypass.port) <> "/index.livemd"
{:ok, session_id} = SessionSupervisor.create_session(origin: {:url, index_url})
{:ok, session} = Sessions.create_session(origin: {:url, index_url})
session_path = "/sessions/#{session_id}"
session_path = "/sessions/#{session.id}"
assert {:error, {:live_redirect, %{to: ^session_path}}} =
result = live(conn, "/sessions/#{session_id}/notebook.livemd")
result = live(conn, "/sessions/#{session.id}/notebook.livemd")
{:ok, view, _} = follow_redirect(result, conn)
assert render(view) =~ "Cannot navigate, failed to download notebook from the given URL"
@ -639,13 +641,13 @@ defmodule LivebookWeb.SessionLiveTest do
index_url = "http://example.com/#{test}/index.livemd"
notebook_url = "http://example.com/#{test}/notebook.livemd"
{:ok, index_session_id} = SessionSupervisor.create_session(origin: {:url, index_url})
{:ok, notebook_session_id} = SessionSupervisor.create_session(origin: {:url, notebook_url})
{:ok, index_session} = Sessions.create_session(origin: {:url, index_url})
{:ok, notebook_session} = Sessions.create_session(origin: {:url, notebook_url})
notebook_session_path = "/sessions/#{notebook_session_id}"
notebook_session_path = "/sessions/#{notebook_session.id}"
assert {:error, {:live_redirect, %{to: ^notebook_session_path}}} =
live(conn, "/sessions/#{index_session_id}/notebook.livemd")
live(conn, "/sessions/#{index_session.id}/notebook.livemd")
end
test "renders an error message if there are already multiple session imported from the relative URL",
@ -653,18 +655,14 @@ defmodule LivebookWeb.SessionLiveTest do
index_url = "http://example.com/#{test}/index.livemd"
notebook_url = "http://example.com/#{test}/notebook.livemd"
{:ok, index_session_id} = SessionSupervisor.create_session(origin: {:url, index_url})
{:ok, index_session} = Sessions.create_session(origin: {:url, index_url})
{:ok, _notebook_session1} = Sessions.create_session(origin: {:url, notebook_url})
{:ok, _notebook_session2} = Sessions.create_session(origin: {:url, notebook_url})
{:ok, _notebook_session_id1} =
SessionSupervisor.create_session(origin: {:url, notebook_url})
{:ok, _notebook_session_id2} =
SessionSupervisor.create_session(origin: {:url, notebook_url})
index_session_path = "/sessions/#{index_session_id}"
index_session_path = "/sessions/#{index_session.id}"
assert {:error, {:live_redirect, %{to: ^index_session_path}}} =
result = live(conn, "/sessions/#{index_session_id}/notebook.livemd")
result = live(conn, "/sessions/#{index_session.id}/notebook.livemd")
{:ok, view, _} = follow_redirect(result, conn)
@ -675,41 +673,41 @@ defmodule LivebookWeb.SessionLiveTest do
# Helpers
defp wait_for_session_update(session_id) do
defp wait_for_session_update(session_pid) do
# This call is synchronous, so it gives the session time
# for handling the previously sent change messages.
Session.get_data(session_id)
Session.get_data(session_pid)
:ok
end
# Utils for sending session requests, waiting for the change to be applied
# and retrieving new ids if applicable.
defp insert_section(session_id) do
Session.insert_section(session_id, 0)
%{notebook: %{sections: [section]}} = Session.get_data(session_id)
defp insert_section(session_pid) do
Session.insert_section(session_pid, 0)
%{notebook: %{sections: [section]}} = Session.get_data(session_pid)
section.id
end
defp insert_text_cell(session_id, section_id, type, content \\ "") do
Session.insert_cell(session_id, section_id, 0, type)
%{notebook: %{sections: [%{cells: [cell]}]}} = Session.get_data(session_id)
defp insert_text_cell(session_pid, section_id, type, content \\ "") do
Session.insert_cell(session_pid, section_id, 0, type)
%{notebook: %{sections: [%{cells: [cell]}]}} = Session.get_data(session_pid)
# We need to register ourselves as a client to start submitting cell deltas
user = Livebook.Users.User.new()
Session.register_client(session_id, self(), user)
Session.register_client(session_pid, self(), user)
delta = Delta.new(insert: content)
Session.apply_cell_delta(session_id, cell.id, delta, 1)
Session.apply_cell_delta(session_pid, cell.id, delta, 1)
wait_for_session_update(session_id)
wait_for_session_update(session_pid)
cell.id
end
defp insert_input_cell(session_id, section_id) do
Session.insert_cell(session_id, section_id, 0, :input)
%{notebook: %{sections: [%{cells: [cell]}]}} = Session.get_data(session_id)
defp insert_input_cell(session_pid, section_id) do
Session.insert_cell(session_pid, section_id, 0, :input)
%{notebook: %{sections: [%{cells: [cell]}]}} = Session.get_data(session_pid)
cell.id
end