Packages

Elixir MOQ client bindings over Rustler NIFs with split publisher/subscriber sessions and draft-14 subgroup/datagram delivery.

Current section

Files

Jump to
moqx lib mix tasks moqtail.demo.debug.ex
Raw

lib/mix/tasks/moqtail.demo.debug.ex

defmodule Mix.Tasks.Moqtail.Demo.Debug do
@moduledoc """
Connects to a relay, fetches a catalog, lets you choose a track, then prints
live runtime stats (bandwidth, groups/s, objects/s, PRFT latency when present).
## Usage
mix moqtail.demo.debug [relay_url] [options]
Examples:
mix moqtail.demo.debug
mix moqtail.demo.debug --track 259
mix moqtail.demo.debug https://ord.abr.moqtail.dev --namespace moqtail
mix moqtail.demo.debug https://ord.abr.moqtail.dev --namespace moqtail --list-tracks-only
Options:
* `--namespace` - catalog/subscription namespace (default: `"moqtail"`).
* `--track` - track name to subscribe to directly (skips interactive prompt).
* `--list-tracks-only` - fetch/subscribe catalog, print tracks, and exit.
* `--timeout` - connect/catalog/subscription timeout in ms (default: `10_000`).
When explicitly provided, it is also used as a max stream runtime;
when it expires, the task exits cleanly.
* `--interval-ms` - stats print interval in ms (default: `1_000`).
* `--delivery-timeout-ms` - passed through to `MOQX.subscribe/4`.
* `--show-raw` - include full per-track raw catalog maps in listing output.
* `--help` - prints this help.
"""
use Mix.Task
@shortdoc "Debug a moqtail relay track with live latency/bandwidth stats"
@requirements ["app.start"]
@default_relay_url "https://ord.abr.moqtail.dev"
@default_namespace "moqtail"
alias MOQX.Catalog.Track
alias MOQX.DemoDebugStats
@impl Mix.Task
def run(args) do
case parse_args(args) do
:help ->
Mix.shell().info(@moduledoc)
{:error, message} ->
Mix.raise(message)
{:ok, config} ->
run_with_config(config)
end
end
defp parse_args(args) do
{opts, positional, invalid} =
OptionParser.parse(args,
strict: [
namespace: :string,
track: :string,
list_tracks_only: :boolean,
timeout: :integer,
interval_ms: :integer,
delivery_timeout_ms: :integer,
show_raw: :boolean,
help: :boolean
]
)
cond do
opts[:help] ->
:help
invalid != [] ->
{:error, "invalid options: #{inspect(invalid)}"}
true ->
url = List.first(positional) || @default_relay_url
timeout = positive_int!(opts[:timeout], :timeout, 10_000)
run_timeout_ms = if(opts[:timeout], do: positive_int!(opts[:timeout], :timeout, nil))
{:ok,
%{
url: url,
namespace: opts[:namespace] || @default_namespace,
track_name: opts[:track],
list_tracks_only: opts[:list_tracks_only] || false,
timeout: timeout,
run_timeout_ms: run_timeout_ms,
interval_ms: positive_int!(opts[:interval_ms], :interval_ms, 1_000),
subscribe_opts: build_subscribe_opts(opts),
show_raw: opts[:show_raw] || false
}}
end
end
defp run_with_config(config) do
Mix.shell().info("connecting to #{config.url} as subscriber...")
:ok = connect_subscriber!(config.url, config.timeout)
subscriber = await_connected!(config.timeout)
try do
Mix.shell().info("loading catalog (namespace=#{config.namespace})...")
catalog = load_catalog!(subscriber, config.namespace, config.timeout)
if config.list_tracks_only do
print_available_tracks!(catalog, config.show_raw)
else
track = choose_track!(catalog, config.track_name, config.show_raw)
Mix.shell().info("subscribing to #{config.namespace}/#{track.name}...")
:ok = MOQX.subscribe(subscriber, config.namespace, track.name, config.subscribe_opts)
await_subscribed!(config.namespace, track.name, config.timeout)
print_stream_start(config.interval_ms, config.run_timeout_ms)
now_mono_ms = System.monotonic_time(:millisecond)
stream_stats_loop(
config.interval_ms,
DemoDebugStats.new(),
now_mono_ms,
config.run_timeout_ms,
now_mono_ms + config.interval_ms
)
end
after
:ok = MOQX.close(subscriber)
end
end
defp build_subscribe_opts(opts) do
case opts[:delivery_timeout_ms] do
nil -> []
ms -> [delivery_timeout_ms: positive_int!(ms, :delivery_timeout_ms, nil)]
end
end
defp print_stream_start(interval_ms, nil) do
Mix.shell().info("streaming stats every #{interval_ms} ms (Ctrl+C to stop)")
end
defp print_stream_start(interval_ms, run_timeout_ms) do
Mix.shell().info(
"streaming stats every #{interval_ms} ms for #{run_timeout_ms} ms (Ctrl+C to stop early)"
)
end
defp connect_subscriber!(url, _timeout) do
case MOQX.connect_subscriber(url) do
:ok ->
:ok
{:error, reason} ->
hint =
if String.contains?(reason, "connection closed by peer") do
" (relay may require a different root path and/or JWT in ?jwt=...)"
else
""
end
Mix.raise("connect failed: #{reason}#{hint}")
end
end
defp await_connected!(timeout) do
receive do
{:moqx_connected, session} -> session
{:error, reason} -> Mix.raise("connect failed: #{reason}")
after
timeout -> Mix.raise("timed out waiting for connect")
end
end
defp load_catalog!(subscriber, namespace, timeout) do
case fetch_catalog(subscriber, namespace, timeout) do
{:ok, catalog} ->
catalog
{:error, reason} ->
if String.contains?(reason, "NoObjects") do
Mix.shell().info("catalog fetch returned NoObjects; falling back to live subscribe...")
subscribe_catalog!(subscriber, namespace, timeout)
else
Mix.raise("catalog load failed: #{reason}")
end
end
end
defp fetch_catalog(subscriber, namespace, timeout) do
with {:ok, ref} <- MOQX.fetch_catalog(subscriber, namespace: namespace),
{:ok, catalog} <- MOQX.await_catalog(ref, timeout) do
{:ok, catalog}
else
{:error, reason} -> {:error, reason}
end
end
defp subscribe_catalog!(subscriber, namespace, timeout) do
:ok = MOQX.subscribe(subscriber, namespace, "catalog")
await_subscribed!(namespace, "catalog", timeout)
receive do
{:moqx_frame, _group_id, payload} ->
case MOQX.Catalog.decode(payload) do
{:ok, catalog} -> catalog
{:error, reason} -> Mix.raise("catalog decode failed: #{reason}")
end
{:moqx_error, reason} ->
Mix.raise("catalog subscribe failed: #{reason}")
after
timeout ->
Mix.raise("timed out waiting for first catalog object")
end
end
defp choose_track!(catalog, nil, show_raw?) do
tracks = print_available_tracks!(catalog, show_raw?)
choose_track_loop(tracks)
end
defp choose_track!(catalog, track_name, _show_raw?) when is_binary(track_name) do
case MOQX.Catalog.get_track(catalog, track_name) do
nil -> Mix.raise("track #{inspect(track_name)} not found in catalog")
track -> track
end
end
defp print_available_tracks!(catalog, show_raw?) do
tracks = MOQX.Catalog.tracks(catalog)
if tracks == [] do
Mix.raise("catalog contains no tracks")
end
Mix.shell().info("available tracks:")
tracks
|> Enum.with_index(1)
|> Enum.each(fn {track, index} ->
Mix.shell().info("#{index}. #{format_track_line(track, show_raw?)}")
end)
tracks
end
defp format_track_line(track, show_raw?) do
summary =
[
"name=#{track.name}",
"role=#{inspect(track.role)}",
"codec=#{inspect(track.codec)}",
"packaging=#{inspect(track.packaging)}"
]
|> Enum.join(" ")
description = Track.describe(track)
lines = [
summary,
" explicit: #{inspect(description.explicit, limit: :infinity, printable_limit: :infinity)}",
" inferred: #{inspect(description.inferred, limit: :infinity, printable_limit: :infinity)}",
" extra: #{inspect(description.extra, limit: :infinity, printable_limit: :infinity)}"
]
lines =
if show_raw? do
raw_lines =
track.raw
|> Enum.sort_by(fn {key, _value} -> key end)
|> Enum.map(fn {key, value} ->
" - #{key}: #{inspect(value, limit: :infinity, printable_limit: :infinity)}"
end)
lines ++ [" raw:" | raw_lines]
else
lines
end
Enum.join(lines, "\n")
end
defp choose_track_loop(tracks) do
max = length(tracks)
case IO.gets("Choose track [1-#{max}] (or q): ") do
:eof ->
Mix.raise("stdin closed")
nil ->
Mix.raise("stdin closed")
input ->
input
|> String.trim()
|> parse_track_selection(tracks, max)
end
end
defp parse_track_selection("q", _tracks, _max), do: Mix.raise("aborted")
defp parse_track_selection("quit", _tracks, _max), do: Mix.raise("aborted")
defp parse_track_selection(value, tracks, max) do
case Integer.parse(value) do
{index, ""} when index >= 1 and index <= max ->
Enum.at(tracks, index - 1)
_ ->
Mix.shell().error("invalid selection")
choose_track_loop(tracks)
end
end
defp await_subscribed!(namespace, track_name, timeout) do
receive do
{:moqx_subscribed, ^namespace, ^track_name} -> :ok
{:moqx_error, reason} -> Mix.raise("subscribe failed: #{reason}")
after
timeout -> Mix.raise("timed out waiting for subscription")
end
end
defp stream_stats_loop(
interval_ms,
stats,
stream_started_mono_ms,
run_timeout_ms,
next_tick_mono_ms
) do
now_mono_ms = System.monotonic_time(:millisecond)
cond do
stream_timed_out?(stream_started_mono_ms, now_mono_ms, run_timeout_ms) ->
Mix.shell().info("stream timeout reached, closing")
now_mono_ms >= next_tick_mono_ms ->
now_ms = System.system_time(:millisecond)
snapshot = DemoDebugStats.snapshot(stats, now_ms)
Mix.shell().info(DemoDebugStats.format_snapshot(snapshot))
stream_stats_loop(
interval_ms,
DemoDebugStats.new(now_ms),
stream_started_mono_ms,
run_timeout_ms,
now_mono_ms + interval_ms
)
true ->
receive_after_ms =
receive_after_ms(next_tick_mono_ms, now_mono_ms, stream_started_mono_ms, run_timeout_ms)
receive do
{:moqx_frame, group_id, payload} ->
stream_stats_loop(
interval_ms,
DemoDebugStats.add_frame(stats, group_id, payload),
stream_started_mono_ms,
run_timeout_ms,
next_tick_mono_ms
)
{:moqx_error, reason} ->
Mix.raise("stream error: #{reason}")
:moqx_track_ended ->
Mix.shell().info("track ended")
after
receive_after_ms ->
stream_stats_loop(
interval_ms,
stats,
stream_started_mono_ms,
run_timeout_ms,
next_tick_mono_ms
)
end
end
end
defp receive_after_ms(next_tick_mono_ms, now_mono_ms, stream_started_mono_ms, run_timeout_ms) do
to_tick_ms = max(next_tick_mono_ms - now_mono_ms, 0)
case run_timeout_ms do
nil ->
to_tick_ms
_ ->
min(to_tick_ms, remaining_runtime_ms(stream_started_mono_ms, now_mono_ms, run_timeout_ms))
end
end
defp stream_timed_out?(_start_mono_ms, _now_mono_ms, nil), do: false
defp stream_timed_out?(start_mono_ms, now_mono_ms, run_timeout_ms) do
remaining_runtime_ms(start_mono_ms, now_mono_ms, run_timeout_ms) == 0
end
defp remaining_runtime_ms(start_mono_ms, now_mono_ms, run_timeout_ms) do
elapsed_ms = max(now_mono_ms - start_mono_ms, 0)
max(run_timeout_ms - elapsed_ms, 0)
end
defp positive_int!(nil, _name, default), do: default
defp positive_int!(value, _name, _default) when is_integer(value) and value > 0, do: value
defp positive_int!(value, name, _default) do
Mix.raise("expected --#{name} to be a positive integer, got: #{inspect(value)}")
end
end