Skip to main content

src/pharos_ffi.erl

%% https://www.erlang.org/docs/29/system/secure_coding.html#code-style
-module(pharos_ffi).

-export([
    %% Telemetry poller wrapper
    start_poller/1,
    list_measurements/1,
    %% Telemetry attach/detach
    attach_many/4,
    detach/1,
    %% Memory unit conversion
    convert_memory/2,
    %% Alert diagnostic
    collect_diagnostic/0,
    %% Custom telemetry measurement emitters
    emit_cluster_nodes/0,
    emit_host_memory/0,
    emit_host_disk/1,
    emit_host_cpu/0,
    emit_host_network/0,
    emit_beam_scheduler/0,
    emit_beam_reductions/0,
    %% Cross-typed-name plumbing for the event bus
    manager_from_name/1,
    %% Supervisor shutdown
    shutdown_supervisor/1,
    %% Metric capture
    now_ms/0,
    %% Hot buffer (ETS circular buffer)
    name_to_atom/1,
    hot_buffer_init/2,
    hot_buffer_push/2,
    hot_buffer_drain/1,
    hot_buffer_peek/1,
    hot_buffer_size/1,
    %% Spillover buffer (Dets cold tier)
    spillover_open/2,
    spillover_push/2,
    spillover_push_all/2,
    spillover_drain/1,
    spillover_size/1,
    %% Connection manager transport
    brain_target/2,
    brain_connect/1,
    brain_deliver/2,
    %% Sink delivery
    encode_etf/1,
    webhook_post/2,
    json_encode/1
]).

%% Politely stop a supervisor (and therefore its tree). Idempotent: a
%% second call against a dead pid is a no-op so `pharos:stop/1` is safe to
%% call multiple times.
shutdown_supervisor(Pid) when is_pid(Pid) ->
    case erlang:is_process_alive(Pid) of
        false ->
            nil;
        true ->
            %% Drop the start_link link first so the supervisor's `shutdown`
            %% exit can't propagate back and take this caller down with it.
            erlang:unlink(Pid),
            Ref = erlang:monitor(process, Pid),
            %% Graceful teardown: a `shutdown` exit signal makes the supervisor
            %% terminate its children (reverse start order, honouring each
            %% child's shutdown timeout) and then exit. Monitor and wait so the
            %% whole tree has finished cleaning up before returning.
            exit(Pid, shutdown),
            receive
                {'DOWN', Ref, process, Pid, _Reason} -> nil
            after 30_000 ->
                %% Safety valve: never let stop/1 hang indefinitely.
                erlang:demonitor(Ref, [flush]),
                nil
            end
    end;
shutdown_supervisor(_Other) ->
    nil.

%% ===========================================================================
%% Alert diagnostic
%% ===========================================================================

%% Capture a brief VM snapshot for the alert diagnostic field.
%% Returns a binary (Gleam String) describing memory usage and process count.
collect_diagnostic() ->
    Memory = erlang:memory(),
    TotalBytes = proplists:get_value(total, Memory, 0),
    TotalMb = TotalBytes / 1_048_576,
    Procs = erlang:system_info(process_count),
    iolist_to_binary(
        io_lib:format("memory=~.2fMb processes=~w", [TotalMb, Procs])
    ).

%% ===========================================================================
%% Telemetry poller
%% ===========================================================================

%% Translate Gleam-encoded poller options into the keyword list shape
%% telemetry_poller expects, then start the poller.
%%
%% Gleam's variant encoding (e.g. `{period, Ms}`, `{builtin, memory}`) does
%% not match telemetry_poller's expected term shapes (e.g. bare atom
%% `memory`, `{process_info, [{name, _}, ...]}`), so we translate at the
%% FFI boundary.
start_poller(GleamOptions) ->
    ErlangOptions = lists:map(fun translate_option/1, GleamOptions),
    case telemetry_poller:start_link(ErlangOptions) of
        {ok, Pid} -> {ok, Pid};
        ignore -> {error, poller_ignored};
        {error, Reason} -> {error, {poller_start_failed, format_reason(Reason)}}
    end.

translate_option({period, Ms}) ->
    {period, Ms};
translate_option({init_delay, Ms}) ->
    {init_delay, Ms};
translate_option({measurements, Specs}) ->
    {measurements, lists:map(fun translate_measurement/1, Specs)}.

translate_measurement({builtin, Name}) ->
    %% Built-in measurements (`memory`, `total_run_queue_lengths`,
    %% `system_counts`, `persistent_term`) are bare atoms in
    %% telemetry_poller's API.
    Name;
translate_measurement({process_info_spec, Name, Event, Keys}) ->
    {process_info, [{name, Name}, {event, Event}, {keys, Keys}]};
translate_measurement({custom, Module, Function, Args}) ->
    {Module, Function, Args}.

%% telemetry_poller normalises measurements into internal
%% {telemetry_poller_builtin, Name, []} tuples when storing them.
%% This shim converts those back to the bare atoms the Gleam side may want.
list_measurements(Poller) ->
    lists:map(fun restore_measurement/1, telemetry_poller:list_measurements(Poller)).

restore_measurement({telemetry_poller_builtin, memory, _}) ->
    memory;
restore_measurement({telemetry_poller_builtin, total_run_queue_lengths, _}) ->
    total_run_queue_lengths;
restore_measurement({telemetry_poller_builtin, system_counts, _}) ->
    system_counts;
restore_measurement({telemetry_poller_builtin, persistent_term, _}) ->
    persistent_term;
restore_measurement(Other) ->
    Other.

%% ===========================================================================
%% Telemetry attach / detach
%% ===========================================================================

%% Wraps a 2-arity Gleam handler in the 4-arity Erlang fun that
%% telemetry:attach_many/4 expects, packing the four arguments into a
%% TelemetryEvent record that Gleam can pattern-match on.
attach_many(HandlerId, Events, GleamHandler, Config) ->
    Handler = fun(EventName, Measurements, Metadata, Cfg) ->
        Event = {telemetry_event, EventName, Measurements, Metadata},
        GleamHandler(Event, Cfg)
    end,
    case telemetry:attach_many(HandlerId, Events, Handler, Config) of
        ok -> {ok, nil};
        {error, already_exists} -> {error, handler_already_attached};
        {error, Reason} -> {error, {attach_failed, format_reason(Reason)}}
    end.

detach(HandlerId) ->
    case telemetry:detach(HandlerId) of
        ok -> {ok, nil};
        {error, not_found} -> {error, handler_not_found};
        {error, Reason} -> {error, {detach_failed, format_reason(Reason)}}
    end.

%% ===========================================================================
%% Memory unit conversion
%% ===========================================================================

convert_memory(Measurements, Unit) ->
    Factor =
        case Unit of
            kb -> 1024;
            mb -> 1_048_576;
            gb -> 1_073_741_824
        end,
    maps:map(fun(_Key, Bytes) -> Bytes / Factor end, Measurements).

%% ===========================================================================
%% Custom telemetry measurement emitters.
%%
%% Each function is invoked by telemetry_poller every polling cycle. They
%% emit telemetry events whose decoded shape lives in pharos/measurement.gleam.
%% ===========================================================================

%% Emit `[pharos, cluster, nodes]`: this node's name plus connected nodes.
emit_cluster_nodes() ->
    Self = erlang:atom_to_binary(erlang:node(), utf8),
    Nodes = [erlang:atom_to_binary(N, utf8) || N <- erlang:nodes()],
    telemetry:execute(
        [pharos, cluster, nodes],
        #{count => length(Nodes)},
        #{self => Self, nodes => Nodes}
    ).

%% Host-memory probe. Reads OS memory totals from `/proc/meminfo` (Linux) and
%% emits `[pharos, host, memory]` with real byte counts and no `status` key
%% (an absent status decodes as `Implemented` on the Gleam side). On platforms
%% without a readable `/proc/meminfo`, degrades to the placeholder zeros +
%% `status: unimplemented` so the event contract holds everywhere.
emit_host_memory() ->
    case read_meminfo() of
        {ok, Total, Available} ->
            Used = max(Total - Available, 0),
            telemetry:execute(
                [pharos, host, memory],
                #{
                    total_bytes => Total,
                    used_bytes => Used,
                    available_bytes => Available
                },
                #{}
            );
        error ->
            telemetry:execute(
                [pharos, host, memory],
                #{total_bytes => 0, used_bytes => 0, available_bytes => 0},
                #{status => unimplemented}
            )
    end.

%% Host-disk probe for the filesystem containing `Path`. Uses os_mon's
%% `disksup:get_disk_info/1` (synchronous, per-path; OTP 26+), which reports
%% total and available space in KiB plus a used-percentage. Emits bytes
%% (KiB * 1024) with no `status` key (an absent status decodes as
%% `Implemented`). Degrades to the placeholder zeros + `status: unimplemented`
%% when os_mon cannot determine the path's usage (it returns `0`s then).
emit_host_disk(Path) when is_binary(Path) ->
    _ = application:ensure_all_started(os_mon),
    case disksup:get_disk_info(erlang:binary_to_list(Path)) of
        [{_Id, TotalKib, AvailKib, _Capacity} | _] when TotalKib > 0 ->
            Total = TotalKib * 1024,
            Available = AvailKib * 1024,
            Used = max(Total - Available, 0),
            telemetry:execute(
                [pharos, host, disk],
                #{
                    total_bytes => Total,
                    used_bytes => Used,
                    available_bytes => Available
                },
                #{}
            );
        _Other ->
            telemetry:execute(
                [pharos, host, disk],
                #{total_bytes => 0, used_bytes => 0, available_bytes => 0},
                #{status => unimplemented}
            )
    end.

%% Host-CPU probe via os_mon's `cpu_sup`. `util/0` returns the busy percentage
%% since the calling process's previous `util/0` call, so cpu_sup tracks the
%% delta itself; the first sample after startup is unreliable (utilisation
%% since boot) and should be treated as garbage. Load averages come back scaled
%% by 256 (256 = load 1.00), so are divided to a plain multiplier.
emit_host_cpu() ->
    _ = application:ensure_all_started(os_mon),
    telemetry:execute(
        [pharos, host, cpu],
        #{
            util => cpu_number(cpu_sup:util()),
            load1 => cpu_number(cpu_sup:avg1()) / 256.0,
            load5 => cpu_number(cpu_sup:avg5()) / 256.0,
            load15 => cpu_number(cpu_sup:avg15()) / 256.0
        },
        #{}
    ).

%% cpu_sup functions return `{error, _}` when the OS port is unavailable; map
%% any non-number to 0 so a probe failure surfaces as a benign zero sample
%% rather than crashing the poller.
cpu_number(Value) when is_number(Value) -> Value;
cpu_number(_Other) -> 0.0.

%% Host-network probe: per-second throughput derived from the cumulative
%% counters in `/proc/net/dev`. The previous snapshot and its capture time live
%% in the calling poller process's dictionary, so deltas are computed without
%% any shared table; a poller restart simply resets the baseline (first sample
%% after that emits zero rates).
emit_host_network() ->
    Now = erlang:monotonic_time(millisecond),
    Totals = proc_net_dev_totals(),
    {RxBps, TxBps, RxPps, TxPps} =
        case erlang:get(pharos_net_prev) of
            {PrevTs, PrevTotals} when Now > PrevTs ->
                net_rates(Totals, PrevTotals, Now - PrevTs);
            _NoPrevOrSameTick ->
                {0.0, 0.0, 0.0, 0.0}
        end,
    erlang:put(pharos_net_prev, {Now, Totals}),
    telemetry:execute(
        [pharos, host, network],
        #{
            rx_bytes_per_sec => RxBps,
            tx_bytes_per_sec => TxBps,
            rx_packets_per_sec => RxPps,
            tx_packets_per_sec => TxPps
        },
        #{}
    ).

%% BEAM scheduler-utilisation probe. `scheduler_wall_time` must be enabled once
%% (idempotent), after which `statistics/1` returns cumulative {Active, Total}
%% busy/total time per scheduler. Utilisation is the ratio of the *deltas*
%% against the previous snapshot (kept in the process dictionary), expressed as
%% a percentage; the first sample after enabling emits zero.
emit_beam_scheduler() ->
    _ = erlang:system_flag(scheduler_wall_time, true),
    {Active, Total} = scheduler_wall_time_totals(),
    Util =
        case erlang:get(pharos_sched_prev) of
            {PrevActive, PrevTotal} when Total > PrevTotal ->
                100.0 * (Active - PrevActive) / (Total - PrevTotal);
            _NoPrevOrSameTick ->
                0.0
        end,
    erlang:put(pharos_sched_prev, {Active, Total}),
    telemetry:execute([pharos, vm, scheduler], #{utilization => Util}, #{}).

%% BEAM reduction-count probe. `erlang:statistics(reductions)` returns
%% `{Total, SinceLastCall}`; the second element is the reductions executed since
%% this BIF was last called, i.e. the work done in the last polling interval.
emit_beam_reductions() ->
    {_Total, SinceLastCall} = erlang:statistics(reductions),
    telemetry:execute([pharos, vm, reductions], #{count => SinceLastCall}, #{}).

%% Sum the receive/transmit byte and packet counters across every non-loopback
%% interface in `/proc/net/dev`. Each data line is
%% `iface: rxbytes rxpackets ... (8 rx cols) txbytes txpackets ... (8 tx cols)`.
proc_net_dev_totals() ->
    case file:read_file("/proc/net/dev") of
        {ok, Bin} ->
            Lines = binary:split(Bin, <<"\n">>, [global]),
            lists:foldl(fun proc_net_dev_line/2, {0, 0, 0, 0}, Lines);
        {error, _Reason} ->
            {0, 0, 0, 0}
    end.

proc_net_dev_line(Line, {Rxb, Txb, Rxp, Txp} = Acc) ->
    case binary:split(Line, <<":">>) of
        [IfaceRaw, Rest] ->
            case string:trim(IfaceRaw) of
                <<"lo">> ->
                    Acc;
                <<>> ->
                    Acc;
                _Iface ->
                    case [string:to_integer(F) || F <- net_fields(Rest)] of
                        [
                            {RxB, _},
                            {RxP, _},
                            _,
                            _,
                            _,
                            _,
                            _,
                            _,
                            {TxB, _},
                            {TxP, _}
                            | _
                        ] when
                            is_integer(RxB),
                            is_integer(RxP),
                            is_integer(TxB),
                            is_integer(TxP)
                        ->
                            {Rxb + RxB, Txb + TxB, Rxp + RxP, Txp + TxP};
                        _Unparsable ->
                            Acc
                    end
            end;
        _NoColon ->
            Acc
    end.

%% Split a `/proc/net/dev` value section into its whitespace-separated fields.
net_fields(Rest) ->
    [F || F <- binary:split(string:trim(Rest), <<" ">>, [global]), F =/= <<>>].

net_rates({Rxb, Txb, Rxp, Txp}, {PRxb, PTxb, PRxp, PTxp}, DtMs) ->
    Dt = DtMs / 1000.0,
    {
        per_sec(Rxb - PRxb, Dt),
        per_sec(Txb - PTxb, Dt),
        per_sec(Rxp - PRxp, Dt),
        per_sec(Txp - PTxp, Dt)
    }.

%% A negative delta means counters reset (interface reset / 32-bit wrap); clamp
%% to zero rather than emit a spurious negative rate.
per_sec(Delta, Dt) when Delta >= 0, Dt > 0 -> Delta / Dt;
per_sec(_Delta, _Dt) -> 0.0.

scheduler_wall_time_totals() ->
    case erlang:statistics(scheduler_wall_time) of
        undefined ->
            {0, 0};
        Schedulers ->
            lists:foldl(
                fun({_Id, Active, Total}, {AccA, AccT}) ->
                    {AccA + Active, AccT + Total}
                end,
                {0, 0},
                Schedulers
            )
    end.

%% ===========================================================================
%% Name-to-Manager cast.
%%
%% At the Erlang level a `Name(AlertEvent)` is just a registered atom and an
%% eparch `Manager(AlertEvent)` is just a pid-or-name reference. gen_event
%% accepts atoms wherever it accepts pids, so this is a zero-cost identity
%% function that crosses the Gleam type boundary.
%% ===========================================================================

manager_from_name(Name) -> Name.

%% ===========================================================================
%% Metric capture
%% ===========================================================================

%% Wall-clock capture timestamp for a Metric, in milliseconds.
now_ms() ->
    erlang:system_time(millisecond).

%% ===========================================================================
%% Hot buffer: a bounded circular buffer of metrics in ETS.
%%
%% The table is an `ordered_set` keyed by a monotonically increasing integer
%% sequence number, so iteration order is insertion order and the oldest data
%% row is always `ets:first/1`. Two bookkeeping rows live in the same table
%% under the fixed atom keys `'$seq'` (next sequence) and `'$capacity'`; since
%% in Erlang term order integers sort before atoms, those rows always sort
%% after every data row and are filtered out of reads with an `is_integer`
%% guard. The table is public + named (the name is the owner process's
%% `new_name` atom, so no extra atoms are minted) and dies with its owner.
%%
%% `public` is required because two distinct processes touch it lock-free: the
%% telemetry handler writes, the connection manager reads/drains. The contents
%% are non-sensitive host/VM metrics (no secrets), so the broad access mode
%% does not expose sensitive data (rule MSC-004).
%% ===========================================================================

%% A `process:name()` is already an atom at runtime (minted once by
%% `gleam_erlang_ffi:new_name/1`). Reusing it as the ETS table name avoids
%% creating any additional atoms: no `list_to_atom/1` on unbounded input.
name_to_atom(Name) -> Name.

hot_buffer_init(Table, Capacity) ->
    %% Idempotent within an owner's lifetime: a fresh owner gets a fresh table
    %% because the previous owner's table died with it.
    Table = ets:new(Table, [
        ordered_set, public, named_table, {write_concurrency, true}, {read_concurrency, true}
    ]),
    ets:insert(Table, {'$seq', 0}),
    ets:insert(Table, {'$capacity', Capacity}),
    nil.

hot_buffer_push(Table, Metric) ->
    Seq = ets:update_counter(Table, '$seq', {2, 1}),
    ets:insert(Table, {Seq, Metric}),
    Capacity = ets:lookup_element(Table, '$capacity', 2),
    case hot_buffer_size(Table) > Capacity of
        true ->
            %% Oldest data row = smallest integer key. The two atom-keyed
            %% bookkeeping rows sort after all integers, so `first/1` is safe
            %% to delete here (there is at least one data row over capacity).
            ets:delete(Table, ets:first(Table)),
            true;
        false ->
            false
    end.

hot_buffer_drain(Table) ->
    Metrics = hot_buffer_peek(Table),
    ets:select_delete(Table, [{{'$1', '_'}, [{is_integer, '$1'}], [true]}]),
    Metrics.

%% ordered_set `select` yields rows in key (= sequence = insertion) order, so
%% the result is oldest-first. The `is_integer` guard skips the bookkeeping rows.
hot_buffer_peek(Table) ->
    ets:select(Table, [{{'$1', '$2'}, [{is_integer, '$1'}], ['$2']}]).

%% Number of buffered metrics: total rows minus the two bookkeeping rows.
hot_buffer_size(Table) ->
    ets:info(Table, size) - 2.

%% ===========================================================================
%% Spillover buffer: an unbounded, disk-backed cold tier in Dets.
%%
%% When the Brain connection is lost and the hot (ETS) buffer fills, the
%% connection manager drains the ring into this Dets table so a long partition
%% never evicts data. On reconnect the manager drains Dets first (oldest data),
%% then the ring, then resumes real-time streaming.
%%
%% Like the hot buffer, rows are `{Seq, Metric}` keyed by a monotonically
%% increasing integer, with one bookkeeping row `{'$seq', N}` holding the next
%% sequence. Unlike ETS `ordered_set`, Dets is a hash table with no iteration
%% order, so `spillover_drain/1` sorts by `Seq` to restore oldest-first.
%% Integer keys sort before the `'$seq'` atom in Erlang term order, and reads
%% select on the `{Seq, Metric}` shape with an `is_integer` guard, so the
%% bookkeeping row is never returned.
%%
%% The Dets file holds non-sensitive host/VM metrics (no secrets), so its
%% on-disk presence does not expose sensitive data (rule MSC-004). The table
%% name reuses the owner's `new_name` atom (no extra atoms minted, rule
%% DSG-003). The owner process opens the file and holds it for its lifetime; an
%% unclean exit leaves the file to Dets's repair-on-open, which recovers it.
%% ===========================================================================

spillover_open(Table, Path) when is_binary(Path) ->
    %% A failed open is unexpected (bad path/permissions) and is left to
    %% propagate so the supervisor surfaces it (rule STL-001).
    {ok, Table} = dets:open_file(Table, [
        {file, erlang:binary_to_list(Path)}, {type, set}, {auto_save, 60000}
    ]),
    %% Seed the sequence counter only on first open; a reopened file keeps its
    %% existing counter (and any unflushed rows) so replay survives a restart.
    dets:insert_new(Table, {'$seq', 0}),
    nil.

spillover_push(Table, Metric) ->
    Seq = dets:update_counter(Table, '$seq', {2, 1}),
    dets:insert(Table, {Seq, Metric}),
    nil.

spillover_push_all(Table, Metrics) ->
    lists:foreach(fun(Metric) -> spillover_push(Table, Metric) end, Metrics),
    nil.

spillover_drain(Table) ->
    Rows = dets:select(Table, [{{'$1', '$2'}, [{is_integer, '$1'}], [{{'$1', '$2'}}]}]),
    %% Dets is unordered; sort by the integer sequence to get oldest-first.
    Sorted = lists:keysort(1, Rows),
    dets:select_delete(Table, [{{'$1', '_'}, [{is_integer, '$1'}], [true]}]),
    [Metric || {_Seq, Metric} <- Sorted].

%% Number of buffered metrics: total rows minus the one bookkeeping row.
spillover_size(Table) ->
    dets:info(Table, size) - 1.

%% ===========================================================================
%% Connection manager: Brain (BEAM-to-BEAM ETF) transport.
%% ===========================================================================

%% Resolve a Brain target's node and registered-process name to atoms ONCE, at
%% transport construction. `Node` and `Name` are operator-supplied config
%% (trusted and bounded, see `services.pharos` / `config.with_brain_stream`),
%% never network input. Resolving here rather than per message keeps atom
%% creation a single, auditable, construction-time event per configured target:
%% message/alert volume can never drive `binary_to_atom`, honouring secure
%% coding rule DSG-003 ("Do Not Abuse Atoms"). `binary_to_existing_atom` is not
%% usable here because a remote node or registered-name atom may legitimately
%% not yet exist locally on first contact. An empty node targets the local node.
brain_target(Node, Name) when is_binary(Node), is_binary(Name) ->
    {brain_target, node_atom(Node), erlang:binary_to_atom(Name, utf8)}.

%% "Connect" to the Brain: confirm the target node is reachable.
brain_connect({brain_target, NodeAtom, _NameAtom}) ->
    case NodeAtom =:= node() orelse net_adm:ping(NodeAtom) =:= pong of
        true -> {ok, nil};
        false -> {error, <<"brain node unreachable">>}
    end.

%% Deliver an already-ETF-encoded payload to the Brain's registered process.
%% Best-effort: `erlang:send/2` to `{Name, Node}` never blocks on the remote.
%% `badarg` (a malformed destination) is the only failure `send/2` raises; any
%% other error is unexpected and is left to propagate (rule STL-001).
brain_deliver({brain_target, NodeAtom, NameAtom}, Payload) when is_binary(Payload) ->
    try
        erlang:send({NameAtom, NodeAtom}, {pharos_alert, Payload}),
        {ok, nil}
    catch
        error:badarg -> {error, <<"invalid brain destination">>}
    end.

node_atom(<<>>) -> node();
node_atom(Node) when is_binary(Node) -> erlang:binary_to_atom(Node, utf8).

%% ===========================================================================
%% Sink delivery helpers.
%% ===========================================================================

%% ETF-encode any term to a binary for the Brain sink.
encode_etf(Term) ->
    erlang:term_to_binary(Term).

%% Fire-and-forget HTTP POST for the notification/webhook sink, using the
%% built-in `httpc` client (inets). Returns ok/error without blocking on a
%% slow endpoint beyond a short timeout. `inets`/`ssl` are started lazily.
webhook_post(Url, Body) when is_binary(Url), is_binary(Body) ->
    _ = application:ensure_all_started(inets),
    _ = application:ensure_all_started(ssl),
    Request = {erlang:binary_to_list(Url), [], "application/json", Body},
    case httpc:request(post, Request, [{timeout, 5000}], [{body_format, binary}]) of
        {ok, _Result} -> {ok, nil};
        {error, Reason} -> {error, format_reason(Reason)}
    end.

%% Encode a string->string map to a JSON object binary using OTP's built-in
%% `json` module (OTP 27+), which handles all escaping. A Gleam
%% `Dict(String, String)` is already an Erlang map of binaries.
json_encode(Map) when is_map(Map) ->
    iolist_to_binary(json:encode(Map)).

%% ===========================================================================
%% Internal
%% ===========================================================================

format_reason(Reason) ->
    iolist_to_binary(io_lib:format("~p", [Reason])).

%% Read `MemTotal` and `MemAvailable` from `/proc/meminfo`, returning their
%% values in bytes. `/proc/meminfo` reports sizes in kB (1024-byte units), so
%% each value is scaled by 1024. Returns `error` if the file is unreadable or
%% either field is absent (e.g. on non-Linux hosts, or kernels predating
%% `MemAvailable`).
read_meminfo() ->
    case file:read_file("/proc/meminfo") of
        {ok, Bin} ->
            Lines = binary:split(Bin, <<"\n">>, [global]),
            Map = lists:foldl(fun parse_meminfo_line/2, #{}, Lines),
            case {maps:find(<<"MemTotal">>, Map), maps:find(<<"MemAvailable">>, Map)} of
                {{ok, TotalKb}, {ok, AvailKb}} ->
                    {ok, TotalKb * 1024, AvailKb * 1024};
                _ ->
                    error
            end;
        {error, _Reason} ->
            error
    end.

%% Fold one `/proc/meminfo` line (e.g. `<<"MemTotal:    16384256 kB">>`) into
%% the accumulator map as `Key => Kb`. Lines that don't parse are skipped.
parse_meminfo_line(Line, Acc) ->
    case binary:split(Line, <<":">>) of
        [Key, Rest] ->
            case parse_kb(Rest) of
                {ok, Kb} -> maps:put(Key, Kb, Acc);
                error -> Acc
            end;
        _Other ->
            Acc
    end.

%% Parse the leading integer (in kB) from a `/proc/meminfo` value such as
%% `<<"    16384256 kB">>`, ignoring surrounding whitespace and the unit suffix.
parse_kb(Rest) ->
    case string:to_integer(string:trim(Rest)) of
        {Kb, _Tail} when is_integer(Kb) -> {ok, Kb};
        _Other -> error
    end.