diff --git a/lib/live_debugger/gen_servers/callback_tracing_server.ex b/lib/live_debugger/gen_servers/callback_tracing_server.ex index 8a88c7430..c68dc1a14 100644 --- a/lib/live_debugger/gen_servers/callback_tracing_server.ex +++ b/lib/live_debugger/gen_servers/callback_tracing_server.ex @@ -1,21 +1,54 @@ defmodule LiveDebugger.GenServers.CallbackTracingServer do @moduledoc """ - This gen_server is responsible for tracing the callbacks of the LiveView processes. + This gen_server is responsible for tracing callbacks and managing ETS tables. """ use GenServer require Logger - alias LiveDebugger.Services.TraceService alias LiveDebugger.Services.ModuleDiscoveryService + alias LiveDebugger.Services.ChannelService + alias LiveDebugger.Services.TraceService alias LiveDebugger.Structs.Trace - alias LiveDebugger.Services.System.ProcessService alias LiveDebugger.Utils.Callbacks, as: CallbackUtils alias LiveDebugger.Utils.PubSub, as: PubSubUtils + @ets_table_name :lvdbg_traces @callback_functions CallbackUtils.callbacks_functions() + @type table_refs() :: %{pid() => :ets.table()} + + ## API + + @doc """ + Returns ETS table reference. + It creates table if none is associated with given pid + """ + @spec table!(pid :: pid()) :: :ets.table() + def table!(pid) when is_pid(pid) do + GenServer.call(__MODULE__, {:get_or_create_table, pid}, 1000) + end + + @doc """ + If table for given `pid` exists it deletes it from ETS. + """ + @spec delete_table!(pid :: pid()) :: :ok + def delete_table!(pid) when is_pid(pid) do + GenServer.call(__MODULE__, {:delete_table, pid}, 1000) + end + + @doc """ + Checks if GenServer has been loaded + """ + @spec ping!() :: :ok + def ping!() do + GenServer.call(__MODULE__, :ping) + end + + ## GenServer + + @doc false def start_link(args \\ []) do GenServer.start_link(__MODULE__, args, name: __MODULE__) end @@ -25,12 +58,12 @@ defmodule LiveDebugger.GenServers.CallbackTracingServer do tracing_setup_delay = Application.get_env(:live_debugger, :tracing_setup_delay, 0) Process.send_after(self(), :setup_tracing, tracing_setup_delay) - {:ok, []} + {:ok, %{}} end @impl true - def handle_info(:setup_tracing, state) do - :dbg.tracer(:process, {&trace_handler/2, 0}) + def handle_info(:setup_tracing, table_refs) do + :dbg.tracer(:process, {&handle_trace/2, 0}) :dbg.p(:all, :c) all_modules = ModuleDiscoveryService.all_modules() @@ -50,16 +83,69 @@ defmodule LiveDebugger.GenServers.CallbackTracingServer do # We trace it to refresh the components tree :dbg.tp({Phoenix.LiveView.Diff, :delete_component, 2}, []) - {:noreply, state} + {:noreply, table_refs} + end + + @impl true + def handle_info({:DOWN, _, :process, closed_pid, _}, table_refs) do + {_, table_refs} = delete_ets_table(closed_pid, table_refs) + + closed_pid + |> PubSubUtils.process_status_topic() + |> PubSubUtils.broadcast({:process_status, :dead}) + + {:noreply, table_refs} + end + + @impl true + def handle_call({:get_or_create_table, pid}, _from, table_refs) do + case Map.get(table_refs, pid) do + nil -> + ref = create_ets_table() + Process.monitor(pid) + {:reply, ref, Map.put(table_refs, pid, ref)} + + ref -> + {:reply, ref, table_refs} + end + end + + @impl true + def handle_call({:delete_table, pid}, _from, table_refs) do + {_, table_refs} = delete_ets_table(pid, table_refs) + {:reply, :ok, table_refs} + end + + @impl true + def handle_call(:ping, _from, state) do + {:reply, :ok, state} + end + + @spec create_ets_table() :: :ets.table() + defp create_ets_table() do + :ets.new(@ets_table_name, [:ordered_set, :public]) + end + + @spec delete_ets_table(pid(), table_refs()) :: {boolean(), table_refs()} + defp delete_ets_table(pid, table_refs) do + case Map.pop(table_refs, pid) do + {nil, table_refs} -> + {false, table_refs} + + {ref, updated_table_refs} -> + :ets.delete(ref) + {true, updated_table_refs} + end end # This handler is heavy because of fetching state and we do not care for order because it is not displayed to user # Because of that we do it asynchronously to speed up tracer a bit # We do not persist this trace because it is not displayed to user - defp trace_handler({_, pid, _, {Phoenix.LiveView.Diff, :delete_component, [cid | _] = args}}, n) do + @spec handle_trace(term(), n :: integer()) :: integer() + defp handle_trace({_, pid, _, {Phoenix.LiveView.Diff, :delete_component, [cid | _] = args}}, n) do Task.start(fn -> with cid <- %Phoenix.LiveComponent.CID{cid: cid}, - {:ok, %{socket: socket}} <- ProcessService.state(pid), + {:ok, %{socket: socket}} <- ChannelService.state(pid), %{id: socket_id, transport_pid: transport_pid} <- socket, true <- is_pid(transport_pid), trace <- @@ -82,7 +168,7 @@ defmodule LiveDebugger.GenServers.CallbackTracingServer do # This handles callbacks created by user that will be displayed to user # It cannot be async because we care about order - defp trace_handler({_, pid, _, {module, fun, args}}, n) when fun in @callback_functions do + defp handle_trace({_, pid, _, {module, fun, args}}, n) when fun in @callback_functions do with trace <- Trace.new(n, module, fun, args, pid), true <- is_pid(trace.transport_pid), :ok <- persist_trace(trace) do @@ -92,15 +178,14 @@ defmodule LiveDebugger.GenServers.CallbackTracingServer do n - 1 end - defp trace_handler(trace, n) do + defp handle_trace(trace, n) do Logger.info("Ignoring unexpected trace: #{inspect(trace)}") n end - defp persist_trace(trace) do - trace.transport_pid - |> TraceService.ets_table_id(trace.socket_id) - |> TraceService.insert(trace.id, trace) + @spec persist_trace(Trace.t()) :: :ok | {:error, term()} + defp persist_trace(%Trace{} = trace) do + TraceService.insert(trace) :ok rescue @@ -109,6 +194,7 @@ defmodule LiveDebugger.GenServers.CallbackTracingServer do {:error, err} end + @spec publish_trace(Trace.t()) :: :ok | {:error, term()} defp publish_trace(%Trace{} = trace) do do_publish(trace) :ok @@ -118,6 +204,7 @@ defmodule LiveDebugger.GenServers.CallbackTracingServer do {:error, err} end + @spec do_publish(Trace.t()) :: :ok defp do_publish(%{module: Phoenix.LiveView.Diff} = trace) do trace |> PubSubUtils.component_deleted_topic() diff --git a/lib/live_debugger/live_views/channel_dashboard_live.ex b/lib/live_debugger/live_views/channel_dashboard_live.ex index 1fc4f92d9..f8bc1e953 100644 --- a/lib/live_debugger/live_views/channel_dashboard_live.ex +++ b/lib/live_debugger/live_views/channel_dashboard_live.ex @@ -115,7 +115,7 @@ defmodule LiveDebugger.LiveViews.ChannelDashboardLive do end def handle_async(:fetch_lv_process, {:ok, fetched_lv_process}, socket) do - Process.monitor(fetched_lv_process.pid) + subscribe_process_state(fetched_lv_process.pid) socket |> assign(:lv_process, AsyncResult.ok(fetched_lv_process)) @@ -138,7 +138,7 @@ defmodule LiveDebugger.LiveViews.ChannelDashboardLive do end @impl true - def handle_info({:DOWN, _, :process, _closed_pid, _}, socket) do + def handle_info({:process_status, :dead}, socket) do socket |> push_patch(to: URL.remove_query_param(socket.assigns.url, "node_id")) |> start_async_assign_lv_process(%{"socket_id" => socket.assigns.socket_id}) @@ -214,4 +214,10 @@ defmodule LiveDebugger.LiveViews.ChannelDashboardLive do push_patch(socket, to: url) end + + defp subscribe_process_state(pid) do + pid + |> PubSubUtils.process_status_topic() + |> PubSubUtils.subscribe!() + end end diff --git a/lib/live_debugger/live_views/traces_live.ex b/lib/live_debugger/live_views/traces_live.ex index 59376158e..d2e7066b5 100644 --- a/lib/live_debugger/live_views/traces_live.ex +++ b/lib/live_debugger/live_views/traces_live.ex @@ -68,7 +68,6 @@ defmodule LiveDebugger.LiveViews.TracesLive do |> assign(node_id: node_id) |> assign(id: session["id"]) |> assign(root_pid: session["root_pid"]) - |> assign(ets_table_id: TraceService.ets_table_id(lv_process)) |> assign(lv_process: lv_process) |> TracingHelper.init() |> assign_async_existing_traces() @@ -290,10 +289,10 @@ defmodule LiveDebugger.LiveViews.TracesLive do @impl true def handle_event("clear-traces", _, socket) do - ets_table_id = socket.assigns.ets_table_id + pid = socket.assigns.lv_process.pid node_id = socket.assigns.node_id - TraceService.clear_traces(ets_table_id, node_id) + TraceService.clear_traces(pid, node_id) socket |> stream(:existing_traces, [], reset: true) @@ -305,7 +304,7 @@ defmodule LiveDebugger.LiveViews.TracesLive do def handle_event("open-trace", %{"data" => string_id}, socket) do trace_id = String.to_integer(string_id) - socket.assigns.ets_table_id + socket.assigns.lv_process.pid |> TraceService.get(trace_id) |> case do nil -> @@ -323,7 +322,7 @@ defmodule LiveDebugger.LiveViews.TracesLive do def handle_event("toggle-collapsible", %{"trace-id" => string_trace_id}, socket) do trace_id = String.to_integer(string_trace_id) - socket.assigns.ets_table_id + socket.assigns.lv_process.pid |> TraceService.get(trace_id) |> case do nil -> @@ -348,7 +347,7 @@ defmodule LiveDebugger.LiveViews.TracesLive do end defp assign_async_existing_traces(socket) do - ets_table_id = socket.assigns.ets_table_id + pid = socket.assigns.lv_process.pid node_id = socket.assigns.node_id active_functions = get_active_functions(socket) @@ -356,7 +355,7 @@ defmodule LiveDebugger.LiveViews.TracesLive do |> assign(:existing_traces_status, :loading) |> stream(:existing_traces, [], reset: true) |> start_async(:fetch_existing_traces, fn -> - TraceService.existing_traces(ets_table_id, + TraceService.existing_traces(pid, node_id: node_id, limit: @page_size, functions: active_functions @@ -365,7 +364,7 @@ defmodule LiveDebugger.LiveViews.TracesLive do end defp load_more_existing_traces(socket) do - ets_table_id = socket.assigns.ets_table_id + pid = socket.assigns.lv_process.pid node_id = socket.assigns.node_id cont = socket.assigns.traces_continuation active_functions = get_active_functions(socket) @@ -373,7 +372,7 @@ defmodule LiveDebugger.LiveViews.TracesLive do socket |> assign(:traces_continuation, :loading) |> start_async(:load_more_existing_traces, fn -> - TraceService.existing_traces(ets_table_id, + TraceService.existing_traces(pid, node_id: node_id, limit: @page_size, cont: cont, diff --git a/lib/live_debugger/services/trace_service.ex b/lib/live_debugger/services/trace_service.ex index 0c7dec4e4..e115a4d02 100644 --- a/lib/live_debugger/services/trace_service.ex +++ b/lib/live_debugger/services/trace_service.ex @@ -1,59 +1,41 @@ defmodule LiveDebugger.Services.TraceService do @moduledoc """ - This module provides functions that manages traces in the debugged application via ETS. - Created table is an ordered_set with non-positive integer keys. + This module is responsible for accessing traces from ETS. + It uses calls to `CallbackTracingServer` to get proper table reference. """ alias LiveDebugger.Structs.Trace alias LiveDebugger.CommonTypes - alias LiveDebugger.Structs.LvProcess + alias LiveDebugger.GenServers.CallbackTracingServer alias Phoenix.LiveComponent.CID - @id_prefix "lvdbg-traces" @default_limit 100 - @doc """ - Returns the ETS table id for the given socket id. - """ - @spec ets_table_id(pid(), String.t()) :: :ets.table() - def ets_table_id(transport_pid, socket_id) do - String.to_atom("#{@id_prefix}-#{inspect(transport_pid)}-#{socket_id}") - end - - @spec ets_table_id(LvProcess.t()) :: :ets.table() - def ets_table_id(%LvProcess{transport_pid: transport_pid, socket_id: socket_id}) do - ets_table_id(transport_pid, socket_id) - end - - @doc """ - Initializes an ETS table with the given id if it doesn't exist. + @type ets_elem() :: {integer(), Trace.t()} + @type ets_continuation :: term() + @typedoc """ + Pid is used to store mapping to table references. + It identifies ETS tables managed by CallbackTracingServer """ - @spec maybe_init_ets(:ets.table()) :: :ets.table() - def maybe_init_ets(ets_table_id) do - if :ets.whereis(ets_table_id) == :undefined do - :ets.new(ets_table_id, [:ordered_set, :public, :named_table]) - else - ets_table_id - end - end + @type ets_table_id() :: pid() @doc """ Inserts a new trace into the ETS table. """ - @spec insert(:ets.table(), integer(), Trace.t()) :: true - def insert(table_id, id, trace) do - table_id - |> maybe_init_ets() + @spec insert(Trace.t()) :: true + def insert(%Trace{pid: pid, id: id} = trace) do + pid + |> ets_table!() |> :ets.insert({id, trace}) end @doc """ - Gets a trace from the ETS table by its id. + Gets a trace of process from the ETS table by `id`. """ - @spec get(:ets.table(), integer()) :: Trace.t() | nil - def get(table_id, id) do - table_id - |> maybe_init_ets() + @spec get(pid :: ets_table_id(), id :: integer()) :: Trace.t() | nil + def get(pid, id) when is_pid(pid) and is_integer(id) do + pid + |> ets_table!() |> :ets.lookup(id) |> case do [] -> nil @@ -62,7 +44,7 @@ defmodule LiveDebugger.Services.TraceService do end @doc """ - Returns existing traces for the given table id with optional filters. + Returns existing traces of a process for the table with optional filters. ## Options * `:node_id` - PID or CID to filter traces by @@ -70,13 +52,14 @@ defmodule LiveDebugger.Services.TraceService do * `:cont` - Used to get next page of items in the following queries * `:functions` - List of function names to filter traces by """ - @spec existing_traces(atom(), keyword()) :: {[Trace.t()], term()} | :end_of_table - def existing_traces(table_id, opts \\ []) do + @spec existing_traces(pid :: ets_table_id(), opts :: keyword()) :: + {[Trace.t()], ets_continuation()} | :end_of_table + def existing_traces(pid, opts \\ []) when is_pid(pid) do opts |> Keyword.get(:cont, nil) |> case do :end_of_table -> :end_of_table - nil -> existing_traces_start(table_id, opts) + nil -> existing_traces_start(pid, opts) _cont -> existing_traces_continuation(opts) end |> case do @@ -92,21 +75,25 @@ defmodule LiveDebugger.Services.TraceService do end @doc """ - Deletes all traces for the given table id and CID or PID. + Deletes traces for LiveView or LiveComponent for given pid. + + * `node_id` - PID or CID which identifies node """ - @spec clear_traces(atom(), pid() | CommonTypes.cid()) :: true - def clear_traces(table_id, %CID{} = cid) do - table_id - |> maybe_init_ets() - |> :ets.match_delete({:_, %{cid: cid}}) + @spec clear_traces(pid :: ets_table_id(), node_id :: pid() | CommonTypes.cid()) :: true + def clear_traces(pid, %CID{} = node_id) when is_pid(pid) do + pid + |> ets_table!() + |> :ets.match_delete({:_, %{cid: node_id}}) end - def clear_traces(table_id, pid) when is_pid(pid) do - table_id - |> maybe_init_ets() - |> :ets.match_delete({:_, %{pid: pid, cid: nil}}) + def clear_traces(pid, node_id) when is_pid(pid) and is_pid(node_id) do + pid + |> ets_table!() + |> :ets.match_delete({:_, %{pid: node_id, cid: nil}}) end + @spec existing_traces_start(ets_table_id(), Keyword.t()) :: + {[ets_elem()], ets_continuation()} | :"$end_of_table" defp existing_traces_start(table_id, opts) do limit = Keyword.get(opts, :limit, @default_limit) functions = Keyword.get(opts, :functions, []) @@ -119,10 +106,12 @@ defmodule LiveDebugger.Services.TraceService do match_spec = match_spec(node_id, functions) table_id - |> maybe_init_ets() + |> ets_table!() |> :ets.select(match_spec, limit) end + @spec existing_traces_continuation(Keyword.t()) :: + {[ets_elem()], ets_continuation()} | :"$end_of_table" defp existing_traces_continuation(opts) do cont = Keyword.get(opts, :cont, nil) @@ -157,4 +146,9 @@ defmodule LiveDebugger.Services.TraceService do [result] end + + @spec ets_table!(pid :: ets_table_id()) :: :ets.table() + defp ets_table!(pid) when is_pid(pid) do + CallbackTracingServer.table!(pid) + end end diff --git a/lib/live_debugger/utils/pubsub.ex b/lib/live_debugger/utils/pubsub.ex index 61d013a72..63de6cc7f 100644 --- a/lib/live_debugger/utils/pubsub.ex +++ b/lib/live_debugger/utils/pubsub.ex @@ -58,6 +58,11 @@ defmodule LiveDebugger.Utils.PubSub do "lvdbg/#{inspect(transport_pid)}/#{socket_id}/component_deleted" end + @spec process_status_topic(pid :: pid()) :: String.t() + def process_status_topic(pid) when is_pid(pid) do + "lvdbg/#{inspect(pid)}/status" + end + @doc """ It stands for transport_pid/socket_id/node_id/function. diff --git a/test/live_debugger/channel_dashboard_test.exs b/test/live_debugger/channel_dashboard_test.exs index 8ce2816ff..febcedef9 100644 --- a/test/live_debugger/channel_dashboard_test.exs +++ b/test/live_debugger/channel_dashboard_test.exs @@ -5,6 +5,8 @@ defmodule LiveDebugger.ChannelDashboardTest do feature "user can see traces of executed callbacks and updated assigns", %{ sessions: [dev_app, debugger] } do + LiveDebugger.GenServers.CallbackTracingServer.ping!() + dev_app |> visit(@dev_app_url)