Current section

Files

Jump to
mob_dev lib mob_dev bench probe.ex
Raw

lib/mob_dev/bench/probe.ex

defmodule MobDev.Bench.Probe do
@moduledoc """
Multi-source state probe for the battery bench.
When a battery read fails, we want to know *why*: is the BEAM dead, just
unreachable, suspended in the background? The probe walks a short pipeline
of network checks and returns a typed state. The bench uses this to
produce informative trace lines and decide whether to attempt reconnection.
## State derivation
epmd_reachable? Node.connect rpc_ping → state
────────────── ───────────── ────────────── ─────
false — — :unreachable
true false — :alive_epmd_only
true true timeout :alive_dist_only
true true ok :alive_rpc
When a `hw_udid` is provided and `ideviceinfo` is available, we additionally
probe USB battery readiness:
ideviceinfo battery → :usb_ok | :usb_failed | :no_usb
And app-process liveness via `xcrun devicectl device info processes`:
app_pid_alive? → :app_running | :app_dead | :app_unknown
All probes are independent and failure-tolerant — any single probe failing
doesn't crash the whole snapshot.
"""
defstruct [
:ts_ms,
:reachability,
:app_process,
:usb,
:screen,
:battery_pct,
:reason
]
@typedoc """
- `:alive_rpc` — RPC just succeeded; BEAM is fully responsive
- `:alive_dist_only` — Node.connect works, RPC times out (suspended?)
- `:alive_epmd_only` — TCP to EPMD works, dist refused (BEAM up but no dist)
- `:unreachable` — Phone offline or BEAM dead
"""
@type reachability :: :alive_rpc | :alive_dist_only | :alive_epmd_only | :unreachable
@typedoc "Foreground/background/dead, or unknown if we can't tell."
@type app_process :: :app_running | :app_suspended | :app_dead | :app_unknown
@typedoc "USB battery readiness via ideviceinfo."
@type usb :: :usb_ok | :usb_failed | :no_usb
@typedoc "Screen state, where derivable. `:unknown` is the honest default."
@type screen :: :on | :off | :unknown
@type t :: %__MODULE__{
ts_ms: integer(),
reachability: reachability(),
app_process: app_process(),
usb: usb(),
screen: screen(),
battery_pct: integer() | nil,
reason: String.t() | nil
}
@doc """
Run the full probe and return a populated state struct.
Common options:
- `:platform``:ios` (default) or `:android` — selects which USB / app-
process probes to run
- `:node` — node atom to probe (required for dist/RPC checks)
- `:host` — IP/host for EPMD probe (defaults to host portion of `:node`)
- `:rpc_timeout_ms` — defaults to 2000
- `:tcp_timeout_ms` — defaults to 1000
- `:expected_screen``:on | :off | :unknown` — what we *believe* the
screen state to be (e.g. after `lock_screen`). Recorded with the snapshot.
iOS-specific:
- `:hw_udid` — hardware UDID for `ideviceinfo` USB probe
- `:device_id` — CoreDevice UUID for `devicectl` process check
- `:app_pid` — pid launched at bench start; checked against `device_id`
Android-specific:
- `:adb_serial` — ADB serial / IP:port for `adb shell` battery + process probes
- `:bundle_id` — app bundle identifier for the process-running check
"""
@spec snapshot(keyword()) :: t()
def snapshot(opts \\ []) do
ts = System.monotonic_time(:millisecond)
platform = Keyword.get(opts, :platform, :ios)
node = Keyword.get(opts, :node)
host = Keyword.get(opts, :host) || derive_host(node)
rpc_timeout = Keyword.get(opts, :rpc_timeout_ms, 2_000)
tcp_timeout = Keyword.get(opts, :tcp_timeout_ms, 1_000)
reachability = probe_reachability(node, host, rpc_timeout, tcp_timeout)
{rpc_pct, rpc_reason} = probe_rpc_battery(reachability, node, rpc_timeout)
{usb, usb_pct, usb_reason} = probe_usb(platform, opts)
app_process = probe_app_process(platform, opts, reachability)
screen = derive_screen(opts[:expected_screen], reachability, usb)
# Battery: prefer USB (more reliable), fall back to RPC, else nil.
# Only surface a reason when we couldn't get a battery reading at all —
# otherwise the CSV's reason column gets flooded with fallback noise
# (e.g. "ideviceinfo: device not found" when the user unplugged USB
# for the bench, even though RPC succeeded right after).
{battery, reason} =
cond do
is_integer(usb_pct) -> {usb_pct, nil}
is_integer(rpc_pct) -> {rpc_pct, nil}
rpc_reason -> {nil, rpc_reason}
usb_reason -> {nil, usb_reason}
true -> {nil, nil}
end
%__MODULE__{
ts_ms: ts,
reachability: reachability,
app_process: app_process,
usb: usb,
screen: screen,
battery_pct: battery,
reason: reason
}
end
# ── Reachability pipeline ────────────────────────────────────────────────
@doc false
@spec probe_reachability(node() | nil, String.t() | nil, timeout(), timeout()) ::
reachability()
def probe_reachability(nil, _host, _rpc_timeout, _tcp_timeout), do: :unreachable
def probe_reachability(_node, nil, _rpc_timeout, _tcp_timeout), do: :unreachable
def probe_reachability(node, host, rpc_timeout, tcp_timeout) do
cond do
not tcp_open?(host, 4369, tcp_timeout) ->
:unreachable
not dist_connected?(node) ->
:alive_epmd_only
not rpc_responsive?(node, rpc_timeout) ->
:alive_dist_only
true ->
:alive_rpc
end
end
@doc false
@spec tcp_open?(String.t() | term(), :inet.port_number(), timeout()) :: boolean()
def tcp_open?(host, port, timeout_ms) when is_binary(host) do
case :gen_tcp.connect(String.to_charlist(host), port, [:binary, active: false], timeout_ms) do
{:ok, sock} ->
:gen_tcp.close(sock)
true
{:error, _} ->
false
end
end
def tcp_open?(_, _, _), do: false
@doc false
@spec dist_connected?(node()) :: boolean()
def dist_connected?(node) when is_atom(node) do
case Node.list() do
list when is_list(list) -> node in list or node == Node.self()
_ -> false
end
end
@doc false
@spec rpc_responsive?(node(), timeout()) :: boolean()
def rpc_responsive?(node, timeout_ms) when is_atom(node) do
case :rpc.call(node, :erlang, :node, [], timeout_ms) do
n when is_atom(n) and n != :nonode@nohost -> true
_ -> false
end
end
# ── Battery via RPC ──────────────────────────────────────────────────────
defp probe_rpc_battery(:alive_rpc, node, timeout_ms) do
case :rpc.call(node, :mob_nif, :battery_level, [], timeout_ms) do
n when is_integer(n) and n >= 0 and n <= 100 ->
{n, nil}
n when is_integer(n) ->
{nil, "rpc battery: out-of-range #{n}"}
{:badrpc, reason} ->
{nil, "rpc battery: badrpc #{inspect(reason)}"}
other ->
{nil, "rpc battery: unexpected #{inspect(other)}"}
end
end
defp probe_rpc_battery(_, _, _), do: {nil, nil}
# ── USB probe ────────────────────────────────────────────────────────────
defp probe_usb(:ios, opts), do: probe_usb_ios(opts[:hw_udid])
defp probe_usb(:android, opts), do: probe_usb_android(opts[:adb_serial])
defp probe_usb(_, _), do: {:no_usb, nil, nil}
defp probe_usb_ios(nil), do: {:no_usb, nil, nil}
defp probe_usb_ios(hw_udid) when is_binary(hw_udid) do
case System.find_executable("ideviceinfo") do
nil ->
{:no_usb, nil, nil}
_path ->
run_ideviceinfo(hw_udid)
end
end
defp run_ideviceinfo(hw_udid) do
case System.cmd(
"ideviceinfo",
["-u", hw_udid, "-q", "com.apple.mobile.battery", "-k", "BatteryCurrentCapacity"],
stderr_to_stdout: true
) do
{out, 0} ->
case Integer.parse(String.trim(out)) do
{n, _} when n in 0..100 -> {:usb_ok, n, nil}
_ -> {:usb_failed, nil, "ideviceinfo: empty/unparsed output"}
end
{out, _} ->
{:usb_failed, nil, "ideviceinfo: " <> String.trim(out)}
end
rescue
e -> {:usb_failed, nil, "ideviceinfo raised: #{Exception.message(e)}"}
end
# Android: parse `adb shell dumpsys battery` for the level field. We use
# battery percentage (not the µAh charge counter) so the snapshot field
# is consistent across platforms.
defp probe_usb_android(nil), do: {:no_usb, nil, nil}
defp probe_usb_android(serial) when is_binary(serial) do
case System.find_executable("adb") do
nil ->
{:no_usb, nil, nil}
_ ->
run_adb_battery(serial)
end
end
defp run_adb_battery(serial) do
case System.cmd("adb", ["-s", serial, "shell", "dumpsys", "battery"], stderr_to_stdout: true) do
{out, 0} ->
case Regex.run(Regex.compile!("^\\s*level:\\s*(\\d+)", "m"), out) do
[_, n_str] ->
case Integer.parse(n_str) do
{n, _} when n in 0..100 -> {:usb_ok, n, nil}
_ -> {:usb_failed, nil, "adb battery: bad level value #{n_str}"}
end
nil ->
{:usb_failed, nil, "adb battery: no level field in dumpsys output"}
end
{out, _} ->
{:usb_failed, nil, "adb battery: " <> String.trim(out)}
end
rescue
e -> {:usb_failed, nil, "adb raised: #{Exception.message(e)}"}
end
# ── App process probe ────────────────────────────────────────────────────
# If RPC just succeeded, the app is definitely running (foreground or
# background — RPC works in both). If RPC failed but EPMD is open, the
# BEAM is alive but might be suspended. If both fail, the app might be
# dead OR the phone is offline; the platform-specific probe disambiguates.
defp probe_app_process(_platform, _opts, :alive_rpc), do: :app_running
defp probe_app_process(_platform, _opts, :alive_dist_only), do: :app_suspended
defp probe_app_process(:ios, opts, _reachability),
do: probe_app_process_ios(opts[:device_id], opts[:app_pid])
defp probe_app_process(:android, opts, _reachability),
do: probe_app_process_android(opts[:adb_serial], opts[:bundle_id])
defp probe_app_process(_, _, _), do: :app_unknown
defp probe_app_process_ios(nil, _pid), do: :app_unknown
defp probe_app_process_ios(_device_id, nil), do: :app_unknown
defp probe_app_process_ios(device_id, pid) when is_integer(pid) do
case System.find_executable("xcrun") do
nil ->
:app_unknown
_ ->
case System.cmd(
"xcrun",
[
"devicectl",
"device",
"info",
"processes",
"--device",
device_id,
"--pid",
to_string(pid)
],
stderr_to_stdout: true
) do
{out, 0} ->
if String.contains?(out, to_string(pid)), do: :app_running, else: :app_dead
_ ->
:app_dead
end
end
rescue
_ -> :app_unknown
end
# Android: `adb shell pidof <pkg>` returns the pid (or empty if not running).
# `pidof` is available on Android 6+ which is well below any device Mob
# currently targets.
defp probe_app_process_android(nil, _bundle), do: :app_unknown
defp probe_app_process_android(_serial, nil), do: :app_unknown
defp probe_app_process_android(serial, bundle) when is_binary(serial) and is_binary(bundle) do
case System.find_executable("adb") do
nil ->
:app_unknown
_ ->
case System.cmd("adb", ["-s", serial, "shell", "pidof", bundle], stderr_to_stdout: true) do
{out, 0} ->
case String.trim(out) do
"" ->
:app_dead
pid_str ->
case Integer.parse(pid_str) do
{n, _} when n > 0 -> :app_running
_ -> :app_dead
end
end
_ ->
:app_dead
end
end
rescue
_ -> :app_unknown
end
# ── Screen state ─────────────────────────────────────────────────────────
# We don't have a clean USB-side iOS API for "is the screen on?". Best
# signals available:
# - `:expected_screen` — what we believe based on our own lock action
# - If RPC works during a screen-off bench, the BEAM is awake even though
# the screen is locked — so screen state is independent of RPC state
#
# We honor the caller's expectation. Future: subscribe to Mob.Device's
# protected_data_will_become_unavailable / available events via RPC for
# ground-truth screen state.
defp derive_screen(:on, _reachability, _usb), do: :on
defp derive_screen(:off, _reachability, _usb), do: :off
defp derive_screen(_, _, _), do: :unknown
# ── Helpers ──────────────────────────────────────────────────────────────
defp derive_host(node), do: MobDev.NodeUtil.host_from_node(node)
@doc """
Format the probe result as a one-line trace fragment.
iex> probe = %MobDev.Bench.Probe{
...> ts_ms: 0, reachability: :alive_rpc, app_process: :app_running,
...> usb: :no_usb, screen: :off, battery_pct: 87, reason: nil
...> }
iex> MobDev.Bench.Probe.format(probe)
"screen:off app:running rpc:ok battery:87%"
"""
@spec format(t()) :: String.t()
def format(%__MODULE__{} = p) do
parts = [
"screen:#{abbreviate_screen(p.screen)}",
"app:#{abbreviate_app(p.app_process)}",
reachability_short(p.reachability),
battery_short(p.battery_pct)
]
Enum.reject(parts, &(&1 == "")) |> Enum.join(" ")
end
defp abbreviate_screen(:on), do: "on"
defp abbreviate_screen(:off), do: "off"
defp abbreviate_screen(_), do: "?"
defp abbreviate_app(:app_running), do: "running"
defp abbreviate_app(:app_suspended), do: "suspended"
defp abbreviate_app(:app_dead), do: "dead"
defp abbreviate_app(_), do: "?"
defp reachability_short(:alive_rpc), do: "rpc:ok"
defp reachability_short(:alive_dist_only), do: "rpc:timeout"
defp reachability_short(:alive_epmd_only), do: "rpc:no-dist"
defp reachability_short(:unreachable), do: "rpc:unreachable"
defp battery_short(nil), do: ""
defp battery_short(pct), do: "battery:#{pct}%"
end