-module(spectator_ffi). -export([ get_status/3, get_state/3, sys_suspend/2, sys_resume/2, list_processes/1, get_process_info/2, format_pid/1, get_details/2, format_port/1, list_ets_tables/1, get_ets_data/2, new_ets_table/2, get_word_size/1, opaque_tuple_to_list/1, get_ets_table_info/2, compare_data/2, list_ports/1, get_port_info/2, get_port_details/2, pid_to_string/1, port_to_string/1, pid_from_string/1, port_from_string/1, get_memory_statistics/1, get_system_info/1, truncate_float/1, kill_process/2, hidden_connect_node/1, is_otp_compatible/2 ]). % --------------------------------------------------- % CALL HARNESS % --------------------------------------------------- -define(ERPC_TIMEOUT, 1000). -define(BULK_TIMEOUT, 5000). % Make a function to the local or remote node. % NodeOption is a Gleam option, if passed as Some(node), the call is made via erpc to that node, % otherwise it is made locally to the current node via erlang:apply/3. do_call(NodeOption, Module, Function, Args) -> case NodeOption of none -> apply(Module, Function, Args); {some, NodeName} -> erpc:call( NodeName, Module, Function, Args, ?ERPC_TIMEOUT ) end. % Wrap in try/catch and transform into a Gleam result to_result(Function) -> try {ok, Function()} catch % An error occurred with the ERPC call error:{erpc, Reason} -> {error, {erpc_error, Reason}}; % Process info errors error:process_not_found -> {error, {not_found_error}}; error:process_no_info -> {error, {no_info_error}}; % Standard Erlang errors error:notsup -> {error, {not_supported_error}}; error:badarg -> {error, {bad_argument_error}}; % A function call unexpectedly returned a result of undefined % (we throw this error in nested functions to avoid returning undefined to Gleam) error:returned_undefined -> {error, returned_undefined_error}; % Fallback for any other error _:Reason -> {error, {dynamic_error, Reason}} end. % --------------------------------------------------- % GENERAL HELPERS % --------------------------------------------------- % these are used inside ffi functions but not exported % Normalize a pid, port, or nif resource into a SystemPrimitive() type % also looks up the spectator tag for a pid if it is a process classify_system_primitive(Item) -> case Item of Process when is_pid(Process) -> {process_primitive, Process, get_process_name_option(Process), spectator_tag_manager:get_tag(Process)}; Port when is_port(Port) -> {port_primitive, Port, get_port_name_option(Port)}; NifResource -> {nif_resource_primitive, NifResource} end. % Get the name of a process wrapped in a Gleam Option type get_process_name_option(Pid) -> try case do_call(none, erlang, process_info, [Pid, registered_name]) of {registered_name, Name} -> {some, Name}; _ -> none end catch error:badarg -> none end. % Get the name of a port wrapped in a Gleam Option type get_port_name_option(Port) -> try case do_call(none, erlang, port_info, [Port, name]) of {registered_name, Name} -> {some, Name}; _ -> none end catch error:badarg -> none end. to_option(Input) -> case Input of [] -> none; undefined -> none; Value -> {some, Value} end. % --------------------------------------------------- % PROCESSES % --------------------------------------------------- % List all processes with info in a single batch. % For remote nodes, uses concurrent erpc:send_request to collect all % process info in parallel, reducing N sequential round-trips to 1 batch. list_processes(NodeOption) -> Items = [current_function, initial_call, registered_name, memory, message_queue_len, reductions, status], to_result(fun() -> case NodeOption of none -> lists:filtermap(fun(Pid) -> case erlang:process_info(Pid, Items) of undefined -> false; [] -> false; Info -> {true, build_process_item(Pid, Info)} end end, erlang:processes()); {some, NodeName} -> Pids = erpc:call(NodeName, erlang, processes, [], ?BULK_TIMEOUT), Reqs = [{P, erpc:send_request(NodeName, erlang, process_info, [P, Items])} || P <- Pids], lists:filtermap(fun({Pid, Req}) -> try case erpc:receive_response(Req, ?BULK_TIMEOUT) of undefined -> false; [] -> false; Info -> {true, build_process_item(Pid, Info)} end catch _:_ -> false end end, Reqs) end end). build_process_item(Pid, Info) -> {_Keys, Values} = lists:unzip(Info), T = list_to_tuple(Values), {process_item, Pid, {process_info, element(1, T), element(2, T), case element(3, T) of [] -> none; Name -> {some, Name} end, element(4, T), element(5, T), element(6, T), spectator_tag_manager:get_tag(Pid), element(7, T)}}. % Kill a process % https://www.erlang.org/doc/apps/erts/erlang.html#exit/2 kill_process(NodeOption, Pid) -> to_result(fun() -> do_call(NodeOption, erlang, exit, [Pid, kill]) end). % Get the status of an OTP-compatible process % https://www.erlang.org/doc/apps/stdlib/sys.html#get_status/2 get_status(NodeOption, Name, Timeout) -> to_result(fun() -> {status, Pid, {module, Module}, SItems} = do_call(NodeOption, sys, get_status, [ Name, Timeout ]), {SysState, Parent} = extract_sysstate_and_parent(SItems), {status, Pid, Module, Parent, SysState, SItems} end). % Get the state of an OTP-compatible process or return an error % https://www.erlang.org/doc/apps/stdlib/sys.html#get_state/2 get_state(NodeOption, Name, Timeout) -> to_result(fun() -> do_call(NodeOption, sys, get_state, [Name, Timeout]) end). % From a list of sys state items, extract the sys state and parent into a tuple extract_sysstate_and_parent(SItems) -> extract_sysstate_and_parent(SItems, undefined, undefined). extract_sysstate_and_parent([], SysState, Parent) -> {SysState, Parent}; extract_sysstate_and_parent([H | T], SysState, Parent) -> case H of running -> extract_sysstate_and_parent(T, process_running, Parent); suspended -> extract_sysstate_and_parent(T, process_suspended, Parent); Pid when is_pid(Pid) -> extract_sysstate_and_parent(T, SysState, Pid); _ -> extract_sysstate_and_parent(T, SysState, Parent) end. % sys_suspend(NodeOption, Pid) -> to_result(fun() -> do_call(NodeOption, sys, suspend, [Pid]) end). sys_resume(NodeOption, Pid) -> to_result(fun() -> do_call(NodeOption, sys, resume, [Pid]) end). % Check if a process was started via proc_lib (and thus handles system messages). % Reads $initial_call from the process dictionary — no messages are sent. % This is the same check that observer uses before sending sys:get_status. is_otp_compatible(NodeOption, Pid) -> try case do_call(NodeOption, proc_lib, translate_initial_call, [Pid]) of {proc_lib, init_p, 5} -> false; {_, _, _} -> true; _ -> false end catch _:_ -> false end. % Throw an error if a value is undefined % !! THROWS - wrap in to_result assert_val(Value) -> case Value of undefined -> { error(returned_undefined) }; Val -> Val end. % Get the info of a regular process for display in a list % https://www.erlang.org/doc/apps/erts/erlang.html#process_info/2 % Return is typed as ProcessInfo() in Gleam get_process_info(NodeOption, Name) -> ItemList = [ current_function, initial_call, registered_name, memory, message_queue_len, reductions, status ], to_result(fun() -> P = do_call(NodeOption, erlang, process_info, [Name, ItemList]), case P of undefined -> error(process_not_found); [] -> error(process_no_info); Info -> {_Keys, Values} = lists:unzip(Info), InfoTuple = list_to_tuple(Values), InfoNormalized = { % Prefix to turn into Info() type process_info, element(1, InfoTuple), element(2, InfoTuple), % Convert registered name to option type case element(3, InfoTuple) of [] -> none; RegisteredName -> {some, RegisteredName} end, element(4, InfoTuple), element(5, InfoTuple), element(6, InfoTuple), spectator_tag_manager:get_tag(Name), element(7, InfoTuple) }, InfoNormalized end end). % Get additional details of a process for display in a details view get_details(NodeOption, Name) -> ItemList = [ messages, links, monitored_by, monitors, trap_exit, parent ], to_result(fun() -> P = do_call(NodeOption, erlang, process_info, [Name, ItemList]), case P of undefined -> {error, not_found}; [] -> {error, no_info}; Info -> {_Keys, Values} = lists:unzip(Info), DetailsTuple = list_to_tuple(Values), DetailsNormalized = { % Prefix to turn into Details() type details, % Messages element(1, DetailsTuple), % Links -> we normalize into SystemPrimitive() lists:map( fun classify_system_primitive/1, element(2, DetailsTuple) ), % Monitored By -> we normalize into SystemPrimitive() lists:map( fun classify_system_primitive/1, element(3, DetailsTuple) ), % Monitors -> we normalize into SystemPrimitive() % There is a lot of remapping here, let's break it down: % - We receive the monitors either by id or name based on how they are monitored % - But we don't actually care if a resource is monitored by id or name % - We DO want to know the id of every resource, even if its is monitored by name % - We also want to know the name of the resource if it has one, % even if it is not monitored by that name % - For this reason we look up the respective id/name for each resource. % - We also handle the special case of a remote process monitored by name separately. % - In case an ID lookup fails, we filter out the resource. lists:filtermap( fun(Item) -> case Item of % Local process monitored by name {process, {RegName, Node}} when Node == node() -> case do_call(NodeOption, erlang, whereis, [RegName]) of % If the PID lookup fails, we filter this process out undefined -> false; Pid -> {true, {process_primitive, Pid, {some, RegName}, spectator_tag_manager:get_tag(Pid)}} end; % Remote process monitored by name {process, {RegName, Node}} -> {true, {remote_process_primitive, RegName, Node}}; % Local process monitored by pid {process, Pid} -> {true, {process_primitive, Pid, get_process_name_option(Pid), spectator_tag_manager:get_tag(Pid)}}; % Port monitored by name % (Node is always the local node, it's a legacy field) {port, {RegName, _Node}} -> case do_call(NodeOption, erlang, whereis, [RegName]) of % If the Port ID lookup fails, we filter this port out undefined -> false; Port -> {true, {port_primitive, Port, RegName}} end; % Port monitored by port id {port, PortId} -> {true, {port_primitive, PortId, get_port_name_option(PortId)}} end end, element(4, DetailsTuple) ), % Trap Exit element(5, DetailsTuple), % Parent case element(6, DetailsTuple) of undefined -> none; Parent -> {some, classify_system_primitive(Parent)} end }, DetailsNormalized end end). % --------------------------------------------------- % PORTS % --------------------------------------------------- % Extract the second element from a two element tuple, thrwoing an error if it is undefined % For use in decoding port info responses. % !! THROWS - wrap in to_result extract_val(Tuple) -> assert_val(element(2, Tuple)). % Batched port collection. % Uses erlang:port_info(Port) (returns all fields in one call) instead of % 8 separate port_info(Port, Key) calls per port. % For remote nodes, all requests fly concurrently via erpc:send_request. list_ports(NodeOption) -> to_result(fun() -> case NodeOption of none -> lists:filtermap(fun(Port) -> try Props = erlang:port_info(Port), case Props of undefined -> false; _ -> {true, build_port_item_from_props(Port, Props)} end catch _:_ -> false end end, erlang:ports()); {some, NodeName} -> Ports = erpc:call(NodeName, erlang, ports, [], ?BULK_TIMEOUT), Reqs = [{P, erpc:send_request(NodeName, erlang, port_info, [P])} || P <- Ports], lists:filtermap(fun({Port, Req}) -> try case erpc:receive_response(Req, ?BULK_TIMEOUT) of undefined -> false; Props -> {true, build_port_item_from_props(Port, Props)} end catch _:_ -> false end end, Reqs) end end). build_port_item_from_props(Port, Props) -> {port_item, Port, {port_info, list_to_bitstring(proplists:get_value(name, Props, "")), case proplists:get_value(registered_name, Props, undefined) of undefined -> none; RegName -> {some, RegName} end, classify_system_primitive(proplists:get_value(connected, Props)), case proplists:get_value(os_pid, Props, undefined) of undefined -> none; OsPid -> {some, OsPid} end, proplists:get_value(input, Props, 0), proplists:get_value(output, Props, 0), proplists:get_value(memory, Props, 0), proplists:get_value(queue_size, Props, 0)}}. % Get the info of a port for display in a list % https://www.erlang.org/doc/apps/erts/erlang.html#port_info/2 % Return is typed as PortInfo() in Gleam get_port_info(NodeOption, Port) -> to_result(fun() -> {port_info, % Port name, this is the command that the port was started with list_to_bitstring( extract_val( do_call(NodeOption, erlang, port_info, [Port, name]) ) ), % Registered name, if the port is registered to_option( do_call(NodeOption, erlang, port_info, [Port, registered_name]) ), % Process connected to the port classify_system_primitive( extract_val( do_call(NodeOption, erlang, port_info, [Port, connected]) ) ), % Operating system process ID to_option( element( 2, do_call(NodeOption, erlang, port_info, [Port, os_pid]) ) ), % Input bytes extract_val( do_call(NodeOption, erlang, port_info, [Port, input]) ), % Output bytes extract_val( do_call(NodeOption, erlang, port_info, [Port, output]) ), % Memory used by the port extract_val( do_call(NodeOption, erlang, port_info, [Port, memory]) ), % Queue size extract_val( do_call(NodeOption, erlang, port_info, [Port, queue_size]) )} end). % Get the details of a port for display in a details view % https://www.erlang.org/doc/apps/erts/erlang.html#port_info/2 % Return is typed as PortDetails() in Gleam get_port_details(NodeOption, Port) -> to_result(fun() -> {port_details, % Links -> we normalize into SystemPrimitive() lists:map( fun classify_system_primitive/1, extract_val( do_call(NodeOption, erlang, port_info, [Port, links]) ) ), % Monitored By -> we normalize into SystemPrimitive() lists:map( fun classify_system_primitive/1, extract_val( do_call(NodeOption, erlang, port_info, [Port, monitored_by]) ) ), % Monitors -> we normalize into SystemPrimitive() lists:map( fun classify_system_primitive/1, extract_val( do_call(NodeOption, erlang, port_info, [Port, monitors]) ) )} end). % --------------------------------------------------- % ETS % --------------------------------------------------- % Construct a Table() type from an ETS table ID % !! THROWS - wrap in to_result build_table_info(NodeOption, Table) -> {table, assert_val(do_call(NodeOption, ets, info, [Table, id])), assert_val(do_call(NodeOption, ets, info, [Table, name])), assert_val(do_call(NodeOption, ets, info, [Table, type])), assert_val(do_call(NodeOption, ets, info, [Table, size])), assert_val(do_call(NodeOption, ets, info, [Table, memory])), classify_system_primitive(assert_val(do_call(NodeOption, ets, info, [Table, owner]))), assert_val(do_call(NodeOption, ets, info, [Table, protection])), assert_val(do_call(NodeOption, ets, info, [Table, read_concurrency])), assert_val(do_call(NodeOption, ets, info, [Table, write_concurrency]))}. % Batched ETS table collection. % Uses ets:info(Table) (returns all fields in one call) instead of % 9 separate ets:info(Table, Key) calls per table. % For remote nodes, all requests fly concurrently via erpc:send_request. list_ets_tables(NodeOption) -> to_result(fun() -> case NodeOption of none -> lists:filtermap(fun(Table) -> case ets:info(Table) of undefined -> false; Props -> {true, build_table_from_props(Props)} end end, ets:all()); {some, NodeName} -> Tables = erpc:call(NodeName, ets, all, [], ?BULK_TIMEOUT), Reqs = [{T, erpc:send_request(NodeName, ets, info, [T])} || T <- Tables], lists:filtermap(fun({_T, Req}) -> try case erpc:receive_response(Req, ?BULK_TIMEOUT) of undefined -> false; Props -> {true, build_table_from_props(Props)} end catch _:_ -> false end end, Reqs) end end). build_table_from_props(Props) -> Owner = proplists:get_value(owner, Props), {table, proplists:get_value(id, Props), proplists:get_value(name, Props), proplists:get_value(type, Props), proplists:get_value(size, Props), proplists:get_value(memory, Props), classify_system_primitive(Owner), proplists:get_value(protection, Props), proplists:get_value(read_concurrency, Props), proplists:get_value(write_concurrency, Props)}. % Return the info of a single ETS table as a Table() type get_ets_table_info(NodeOption, Table) -> to_result(fun() -> case do_call(NodeOption, ets, info, [Table, id]) of undefined -> error(returned_undefined); TableId -> build_table_info(NodeOption, TableId) end end). % Return the data of an ETS table get_ets_data(NodeOption, Table) -> to_result(fun() -> do_call(NodeOption, ets, match, [Table, '$1']) end). % Create a new ETS table new_ets_table(NodeOption, Name) -> to_result(fun() -> do_call(NodeOption, ets, new, [Name, [named_table, public]]) end). % --------------------------------------------------- % SYSTEM INFO % --------------------------------------------------- % Get system info, as a SystemInfo() type get_system_info(NodeOption) -> to_result(fun() -> { system_info, % Uptime String uptime_string(NodeOption), % Architecture list_to_bitstring( do_call(NodeOption, erlang, system_info, [system_architecture]) ), % ERTS version list_to_bitstring( do_call(NodeOption, erlang, system_info, [version]) ), % OTP release list_to_bitstring( do_call(NodeOption, erlang, system_info, [otp_release]) ), % Schedulers do_call(NodeOption, erlang, system_info, [schedulers]), % Schedulers online do_call(NodeOption, erlang, system_info, [schedulers_online]), % Atom count do_call(NodeOption, erlang, system_info, [atom_count]), % Atom limit do_call(NodeOption, erlang, system_info, [atom_limit]), % ETS count do_call(NodeOption, erlang, system_info, [ets_count]), % ETS limit do_call(NodeOption, erlang, system_info, [ets_limit]), % Port count do_call(NodeOption, erlang, system_info, [port_count]), % Port limit do_call(NodeOption, erlang, system_info, [port_limit]), % Process count do_call(NodeOption, erlang, system_info, [process_count]), % Process limit do_call(NodeOption, erlang, system_info, [process_limit]) } end). % Get memory stats % as a MemoryStatistics() type get_memory_statistics(NodeOption) -> to_result(fun() -> list_to_tuple([ memory_statistics | element( 2, lists:unzip( do_call(NodeOption, erlang, memory, []) ) ) ]) end). get_word_size(NodeOption) -> to_result(fun() -> do_call(NodeOption, erlang, system_info, [wordsize]) end). % !! THROWS - wrap in to_result uptime_seconds(NodeOption) -> NativeUptime = do_call(NodeOption, erlang, monotonic_time, []) - do_call(none, erlang, system_info, [start_time]), do_call(NodeOption, erlang, convert_time_unit, [NativeUptime, native, seconds]). % !! THROWS - wrap in to_result uptime_string(NodeOption) -> {D, {H, M, S}} = do_call(NodeOption, calendar, seconds_to_daystime, [uptime_seconds(NodeOption)]), list_to_bitstring( io_lib:format("~p days ~p hours ~p minutes ~p seconds", [D, H, M, S]) ). % --------------------------------------------------- % LOCAL HELPERS % --------------------------------------------------- % (these are exported but are not related to node inspection) hidden_connect_node(Node) -> to_result(fun() -> net_kernel:hidden_connect_node(Node) end). pid_to_string(Pid) -> list_to_bitstring(pid_to_list(Pid)). truncate_float(F) -> list_to_bitstring(io_lib:format("~.2f", [F])). port_to_string(Port) -> list_to_bitstring(port_to_list(Port)). pid_from_string(String) -> try {ok, list_to_pid(bitstring_to_list(String))} catch error:badarg -> {error, nil} end. port_from_string(String) -> try {ok, list_to_port(bitstring_to_list(String))} catch error:badarg -> {error, nil} end. format_pid(Pid) -> list_to_bitstring(pid_to_list(Pid)). format_port(Port) -> list_to_bitstring(port_to_list(Port)). compare_data(Data1, Data2) when Data1 < Data2 -> lt; compare_data(Data1, Data2) when Data1 > Data2 -> gt; compare_data(_Data1, _Data2) -> eq. opaque_tuple_to_list(Tuple) -> tuple_to_list(Tuple).