chore(dialyzer): enable stricter flags and fix 97 resulting findings

Turned on :error_handling, :underspecs, and :unmatched_returns in
mix.exs dialyzer config. The 97 warnings this surfaced were fixed in
place rather than suppressed:

- unmatched_return (79): explicit discard with `_ = ...` for
  fire-and-forget side effects (Process.cancel_timer, :ets.new,
  send/2), and pattern-matched `:ok = ...` for control-plane
  Phoenix.PubSub subscribe/unsubscribe/broadcast calls so a future
  return-shape change fails loud.

- contract_supertype (18): tightened @spec arg and return types on
  data_builder, historical_loader, url_params, packet_utils,
  encoding_utils, aprs_symbol, weather_controller, packet_replay to
  match each function's actual success typing.

No behavioural change. mix compile clean, 1008 tests pass, dialyzer
count is now 0.
This commit is contained in:
Graham McIntire 2026-04-21 10:07:00 -05:00
parent 8332e5c53c
commit b8a9b8a465
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
40 changed files with 372 additions and 276 deletions

View file

@ -10,7 +10,7 @@ defmodule Aprsme.Application do
@impl true
def start(_type, _args) do
# Initialize deployment timestamp
Aprsme.Release.init()
_ = Aprsme.Release.init()
# Run migrations on startup
migrate()
@ -83,17 +83,18 @@ defmodule Aprsme.Application do
{:ok, sup} = Supervisor.start_link(children, opts)
# Attach error notification telemetry handler
Aprsme.ErrorNotifier.attach()
_ = Aprsme.ErrorNotifier.attach()
# Now that the Repo is started, run the refresh in a background task
# Skip in test environment to avoid DBConnection.OwnershipError
env = Application.get_env(:aprsme, :env)
if env != :test do
Aprsme.BroadcastTaskSupervisor.async_execute(fn ->
Aprsme.DeviceIdentification.maybe_refresh_devices()
end)
end
_ =
if env != :test do
Aprsme.BroadcastTaskSupervisor.async_execute(fn ->
Aprsme.DeviceIdentification.maybe_refresh_devices()
end)
end
{:ok, sup}
end
@ -191,11 +192,11 @@ defmodule Aprsme.Application do
# Create ETS tables for caching — use :public so the Cache GenServer (a separate process)
# can write to these tables; write_concurrency improves throughput under concurrent writes.
:ets.new(:query_cache, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true])
:ets.new(:device_cache, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true])
:ets.new(:symbol_cache, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true])
:ets.new(:aprsme, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true])
:ets.insert(:aprsme, {:message_number, 0})
_ = :ets.new(:query_cache, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true])
_ = :ets.new(:device_cache, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true])
_ = :ets.new(:symbol_cache, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true])
_ = :ets.new(:aprsme, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true])
_ = :ets.insert(:aprsme, {:message_number, 0})
[
# ETS-based rate limiter

View file

@ -180,7 +180,7 @@ defmodule Aprsme.CircuitBreaker do
{:ok, result}
catch
:exit, {:timeout, _} ->
Task.shutdown(task, :brutal_kill)
_ = Task.shutdown(task, :brutal_kill)
record_failure(service_name)
Logger.warning("Service call timed out",

View file

@ -37,14 +37,15 @@ defmodule Aprsme.CleanupScheduler do
def handle_info(:schedule_cleanup, %{interval: interval} = state) when is_integer(interval) do
Logger.info("Running packet cleanup task")
Aprsme.BroadcastTaskSupervisor.async_execute(fn ->
try do
PacketCleanupWorker.perform(%{})
rescue
error ->
Logger.error("Packet cleanup task failed: #{inspect(error)}")
end
end)
_ =
Aprsme.BroadcastTaskSupervisor.async_execute(fn ->
try do
PacketCleanupWorker.perform(%{})
rescue
error ->
Logger.error("Packet cleanup task failed: #{inspect(error)}")
end
end)
schedule_next_cleanup(interval)
{:noreply, state}

View file

@ -17,11 +17,11 @@ defmodule Aprsme.Cluster.ConnectionManager do
@impl true
def init(_opts) do
# Subscribe to leadership changes
Phoenix.PubSub.subscribe(Aprsme.PubSub, "cluster:leadership")
:ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "cluster:leadership")
# Check initial leadership state
delay = Application.get_env(:aprsme, :connection_manager_init_delay, 1000)
Process.send_after(self(), :check_initial_state, delay)
_ = Process.send_after(self(), :check_initial_state, delay)
{:ok, %{connection_started: false}}
end

View file

@ -127,11 +127,12 @@ defmodule Aprsme.Cluster.LeaderElection do
case :global.whereis_name(@election_key) do
pid when pid == self() ->
if !state.is_leader do
Logger.info("Re-confirming leadership on node #{node()}")
:persistent_term.put({__MODULE__, :is_leader}, true)
notify_leadership_change(true)
end
_ =
if !state.is_leader do
Logger.info("Re-confirming leadership on node #{node()}")
:persistent_term.put({__MODULE__, :is_leader}, true)
:ok = notify_leadership_change(true)
end
{:noreply, %{state | is_leader: true, leader_node: node()}}
@ -145,12 +146,13 @@ defmodule Aprsme.Cluster.LeaderElection do
state = verify_leadership(state)
# Re-attempt election if we're not leader
if not state.is_leader do
Process.send_after(self(), :attempt_election, 100)
end
_ =
if not state.is_leader do
Process.send_after(self(), :attempt_election, 100)
end
# Schedule next check
Process.send_after(self(), :check_leadership, @check_interval)
_ = Process.send_after(self(), :check_leadership, @check_interval)
{:noreply, state}
end
@ -173,12 +175,13 @@ defmodule Aprsme.Cluster.LeaderElection do
@impl true
def terminate(reason, state) do
if state.is_leader do
Logger.info("Leader stepping down due to: #{inspect(reason)}")
:persistent_term.put({__MODULE__, :is_leader}, false)
:global.unregister_name(@election_key)
notify_leadership_change(false)
end
_ =
if state.is_leader do
Logger.info("Leader stepping down due to: #{inspect(reason)}")
:persistent_term.put({__MODULE__, :is_leader}, false)
_ = :global.unregister_name(@election_key)
notify_leadership_change(false)
end
:ok
end
@ -194,7 +197,7 @@ defmodule Aprsme.Cluster.LeaderElection do
_ ->
Logger.warning("Lost global leadership registration, stepping down")
:persistent_term.put({__MODULE__, :is_leader}, false)
notify_leadership_change(false)
_ = notify_leadership_change(false)
%{state | is_leader: false, leader_node: nil}
end
end
@ -217,7 +220,7 @@ defmodule Aprsme.Cluster.LeaderElection do
:yes ->
Logger.info("Elected as APRS-IS connection leader on node #{node()}")
:persistent_term.put({__MODULE__, :is_leader}, true)
notify_leadership_change(true)
_ = notify_leadership_change(true)
{:noreply, %{state | is_leader: true, leader_node: node()}}
:no ->

View file

@ -45,13 +45,13 @@ defmodule Aprsme.Cluster.PacketDistributor do
@impl true
def init(_opts) do
Phoenix.PubSub.subscribe(Aprsme.PubSub, @pubsub_topic)
:ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, @pubsub_topic)
{:ok, %{}}
end
@impl true
def terminate(_reason, _state) do
Phoenix.PubSub.unsubscribe(Aprsme.PubSub, @pubsub_topic)
_ = Phoenix.PubSub.unsubscribe(Aprsme.PubSub, @pubsub_topic)
:ok
end
end

View file

@ -18,7 +18,7 @@ defmodule Aprsme.Cluster.PacketReceiver do
@impl true
def init(_opts) do
# Subscribe to distributed packets
PacketDistributor.subscribe()
:ok = PacketDistributor.subscribe()
Logger.info("Started packet receiver on node #{node()}")

View file

@ -188,11 +188,12 @@ defmodule Aprsme.ConnectionMonitor do
Logger.info("Draining #{to_drain} connections from node #{Node.self()}")
# Broadcast drain event to LiveViews
Phoenix.PubSub.broadcast(
Aprsme.PubSub,
"connection:drain:#{Node.self()}",
{:drain_connections, to_drain}
)
_ =
Phoenix.PubSub.broadcast(
Aprsme.PubSub,
"connection:drain:#{Node.self()}",
{:drain_connections, to_drain}
)
state
end

View file

@ -77,7 +77,7 @@ defmodule Aprsme.DbOptimizer do
def analyze_table(table_name) do
validate_identifier!(table_name)
quoted_name = quote_identifier(table_name)
SQL.query!(Repo, "ANALYZE #{quoted_name}", [])
_ = SQL.query!(Repo, "ANALYZE #{quoted_name}", [])
:ok
rescue
error ->
@ -100,7 +100,7 @@ defmodule Aprsme.DbOptimizer do
query = "#{vacuum_type}#{analyze_clause} #{quoted_name}"
SQL.query!(Repo, query, [], timeout: :infinity)
_ = SQL.query!(Repo, query, [], timeout: :infinity)
:ok
rescue
error ->

View file

@ -38,11 +38,12 @@ defmodule Aprsme.DeploymentNotifier do
else
Logger.info("Deployment timestamp changed from #{state.deployed_at} to #{current_deployed_at}")
Phoenix.PubSub.broadcast(
Aprsme.PubSub,
"deployment_events",
{:new_deployment, %{deployed_at: current_deployed_at}}
)
_ =
Phoenix.PubSub.broadcast(
Aprsme.PubSub,
"deployment_events",
{:new_deployment, %{deployed_at: current_deployed_at}}
)
{:noreply, %{state | deployed_at: current_deployed_at}}
end

View file

@ -210,7 +210,19 @@ defmodule Aprsme.EncodingUtils do
iex> :foo in Aprsme.EncodingUtils.weather_fields()
false
"""
@spec weather_fields() :: [atom()]
@spec weather_fields() :: [
:temperature
| :humidity
| :wind_speed
| :wind_direction
| :wind_gust
| :pressure
| :rain_1h
| :rain_24h
| :rain_since_midnight
| :snow,
...
]
def weather_fields do
[
:temperature,
@ -371,7 +383,12 @@ defmodule Aprsme.EncodingUtils do
iex> Aprsme.EncodingUtils.encoding_info(<<72, 101, 211, 108, 111>>)
%{valid_utf8: false, byte_count: 5, char_count: nil, invalid_at: 2}
"""
@spec encoding_info(binary()) :: map()
@spec encoding_info(binary()) :: %{
valid_utf8: boolean(),
byte_count: non_neg_integer(),
char_count: non_neg_integer() | nil,
invalid_at: non_neg_integer() | nil
}
def encoding_info(binary) when is_binary(binary) do
Encoding.encoding_info(binary)
end

View file

@ -31,9 +31,10 @@ defmodule Aprsme.ErrorNotifier do
occurrence = metadata.occurrence
# Only send email for first occurrence in production
if should_send_email?(occurrence) do
send_error_email(occurrence)
end
_ =
if should_send_email?(occurrence) do
send_error_email(occurrence)
end
:ok
end

View file

@ -142,7 +142,7 @@ defmodule Aprsme.PacketConsumer do
end
defp reset_batch_timer(%{timer: timer} = state) do
Process.cancel_timer(timer)
_ = Process.cancel_timer(timer)
new_timer = Process.send_after(self(), :process_batch, state.batch_timeout)
%{state | batch: [], batch_length: 0, timer: new_timer}
end
@ -294,7 +294,7 @@ defmodule Aprsme.PacketConsumer do
try do
{inserted_count, _} = Repo.insert_all(Aprsme.Packet, valid_inserts, insert_opts)
broadcast_packets_async(Enum.map(valid_pairs, fn {_insert, bcast} -> bcast end))
_ = broadcast_packets_async(Enum.map(valid_pairs, fn {_insert, bcast} -> bcast end))
{inserted_count, invalid_count}
rescue
@ -305,9 +305,10 @@ defmodule Aprsme.PacketConsumer do
{fallback_inserted, fallback_bcasts} = insert_individually(valid_pairs)
# Broadcast whatever was successfully inserted
if fallback_bcasts != [] do
broadcast_packets_async(fallback_bcasts)
end
_ =
if fallback_bcasts != [] do
broadcast_packets_async(fallback_bcasts)
end
{fallback_inserted, invalid_count + length(valid_pairs) - fallback_inserted}
end
@ -384,15 +385,19 @@ defmodule Aprsme.PacketConsumer do
# Replaces the `aprs_packets` pg_notify path (removed as a DB trigger).
defp broadcast_legacy_topics(packet, routing_callsign, has_weather?) do
Phoenix.PubSub.broadcast(Aprsme.PubSub, "postgres:aprsme_packets", {:postgres_packet, packet})
# Hot path: PubSub.broadcast failures tolerated (logged by PubSub itself).
_ = Phoenix.PubSub.broadcast(Aprsme.PubSub, "postgres:aprsme_packets", {:postgres_packet, packet})
if is_binary(routing_callsign) and routing_callsign != "" do
Phoenix.PubSub.broadcast(Aprsme.PubSub, "packets:#{routing_callsign}", {:postgres_packet, packet})
_ =
if is_binary(routing_callsign) and routing_callsign != "" do
_ = Phoenix.PubSub.broadcast(Aprsme.PubSub, "packets:#{routing_callsign}", {:postgres_packet, packet})
if has_weather? do
Phoenix.PubSub.broadcast(Aprsme.PubSub, "weather:#{routing_callsign}", {:weather_packet, packet})
if has_weather? do
Phoenix.PubSub.broadcast(Aprsme.PubSub, "weather:#{routing_callsign}", {:weather_packet, packet})
end
end
end
:ok
end
defp test_env? do

View file

@ -197,13 +197,14 @@ defmodule Aprsme.PacketReplay do
)
# Send notification to client that replay is starting
Endpoint.broadcast(state.replay_topic, "replay_started", %{
total_packets: Packets.get_historical_packet_count(Map.new(replay_opts)),
start_time: state.start_time,
end_time: state.end_time,
replay_speed: state.replay_speed,
bounds: state.bounds
})
_ =
Endpoint.broadcast(state.replay_topic, "replay_started", %{
total_packets: Packets.get_historical_packet_count(Map.new(replay_opts)),
start_time: state.start_time,
end_time: state.end_time,
replay_speed: state.replay_speed,
bounds: state.bounds
})
# Get packets and start streaming
replay_opts_map =
@ -224,10 +225,11 @@ defmodule Aprsme.PacketReplay do
[] ->
# No packets found, end replay immediately
Endpoint.broadcast(state.replay_topic, "replay_complete", %{
packets_sent: 0,
message: "No matching packets found for replay"
})
_ =
Endpoint.broadcast(state.replay_topic, "replay_complete", %{
packets_sent: 0,
message: "No matching packets found for replay"
})
{:stop, :normal, state}
end
@ -243,11 +245,12 @@ defmodule Aprsme.PacketReplay do
@impl true
def handle_info({:send_packet, packet, stream}, state) do
# Send the packet to the client
Endpoint.broadcast(state.replay_topic, "historical_packet", %{
packet: sanitize_packet_for_transport(packet),
timestamp: packet.received_at,
is_historical: true
})
_ =
Endpoint.broadcast(state.replay_topic, "historical_packet", %{
packet: sanitize_packet_for_transport(packet),
timestamp: packet.received_at,
is_historical: true
})
# Update counter
new_packets_sent = state.packets_sent + 1
@ -264,10 +267,11 @@ defmodule Aprsme.PacketReplay do
[] ->
# No more packets, end replay
Endpoint.broadcast(state.replay_topic, "replay_complete", %{
packets_sent: new_packets_sent,
message: "Replay complete"
})
_ =
Endpoint.broadcast(state.replay_topic, "replay_complete", %{
packets_sent: new_packets_sent,
message: "Replay complete"
})
{:stop, :normal, state}
end
@ -276,14 +280,16 @@ defmodule Aprsme.PacketReplay do
@impl true
@spec handle_call(any(), {pid(), any()}, state()) :: {:reply, any(), state()}
def handle_call(:pause, _from, state) do
if state.replay_timer do
Process.cancel_timer(state.replay_timer)
end
_ =
if state.replay_timer do
Process.cancel_timer(state.replay_timer)
end
Endpoint.broadcast(state.replay_topic, "replay_paused", %{
packets_sent: state.packets_sent,
last_packet_time: state.last_packet_time
})
_ =
Endpoint.broadcast(state.replay_topic, "replay_paused", %{
packets_sent: state.packets_sent,
last_packet_time: state.last_packet_time
})
{:reply, :ok, %{state | paused: true, replay_timer: nil}}
end
@ -293,10 +299,11 @@ defmodule Aprsme.PacketReplay do
# Force the next packet to be sent soon
send(self(), {:continue_replay})
Endpoint.broadcast(state.replay_topic, "replay_resumed", %{
packets_sent: state.packets_sent,
last_packet_time: state.last_packet_time
})
_ =
Endpoint.broadcast(state.replay_topic, "replay_resumed", %{
packets_sent: state.packets_sent,
last_packet_time: state.last_packet_time
})
{:reply, :ok, %{state | paused: false}}
end
@ -310,9 +317,10 @@ defmodule Aprsme.PacketReplay do
@impl true
def handle_call({:set_speed, speed}, _from, state) do
# Update speed and notify client
Endpoint.broadcast(state.replay_topic, "replay_speed_changed", %{
replay_speed: speed
})
_ =
Endpoint.broadcast(state.replay_topic, "replay_speed_changed", %{
replay_speed: speed
})
{:reply, :ok, %{state | replay_speed: speed}}
end
@ -320,9 +328,10 @@ defmodule Aprsme.PacketReplay do
@impl true
def handle_call({:update_filters, filters}, _from, state) do
# Update filters - this requires restarting the replay
if state.replay_timer do
Process.cancel_timer(state.replay_timer)
end
_ =
if state.replay_timer do
Process.cancel_timer(state.replay_timer)
end
# Update state with new filters
new_state =
@ -378,15 +387,17 @@ defmodule Aprsme.PacketReplay do
@spec terminate(any(), state()) :: :ok
def terminate(_reason, state) do
# Clean up any timers
if state.replay_timer do
Process.cancel_timer(state.replay_timer)
end
_ =
if state.replay_timer do
Process.cancel_timer(state.replay_timer)
end
# Notify client that replay has ended
Endpoint.broadcast(state.replay_topic, "replay_stopped", %{
packets_sent: state.packets_sent,
message: "Replay stopped"
})
_ =
Endpoint.broadcast(state.replay_topic, "replay_stopped", %{
packets_sent: state.packets_sent,
message: "Replay stopped"
})
:ok
end
@ -398,7 +409,7 @@ defmodule Aprsme.PacketReplay do
{:via, Registry, {Aprsme.ReplayRegistry, "replay:#{user_id}"}}
end
@spec sanitize_packet_for_transport(any()) :: map()
@spec sanitize_packet_for_transport(struct()) :: map()
defp sanitize_packet_for_transport(packet) do
# Convert to map and ensure all fields are JSON-safe
packet

View file

@ -56,7 +56,7 @@ defmodule Aprsme.PartitionManager do
if partition_exists?(name) do
acc
else
create_partition_with_lock(date)
_ = create_partition_with_lock(date)
[name | acc]
end
end
@ -68,13 +68,14 @@ defmodule Aprsme.PartitionManager do
name = partition_name(date)
lock_key = :erlang.phash2(name)
Repo.transaction(fn ->
Repo.query!("SELECT pg_advisory_xact_lock($1)", [lock_key])
_ =
Repo.transaction(fn ->
_ = Repo.query!("SELECT pg_advisory_xact_lock($1)", [lock_key])
if !partition_exists?(name) do
create_partition(date)
end
end)
if !partition_exists?(name) do
create_partition(date)
end
end)
name
end
@ -96,7 +97,7 @@ defmodule Aprsme.PartitionManager do
end
end)
|> Enum.map(fn name ->
drop_partition(name)
_ = drop_partition(name)
name
end)
@ -181,9 +182,10 @@ defmodule Aprsme.PartitionManager do
validate_partition_name!(name)
Repo.query!(
"CREATE TABLE IF NOT EXISTS #{quote_identifier(name)} PARTITION OF packets FOR VALUES FROM ('#{from_str}') TO ('#{to_str}')"
)
_ =
Repo.query!(
"CREATE TABLE IF NOT EXISTS #{quote_identifier(name)} PARTITION OF packets FOR VALUES FROM ('#{from_str}') TO ('#{to_str}')"
)
Logger.debug("Created partition #{name} [#{from_str}, #{to_str})")
name
@ -193,7 +195,7 @@ defmodule Aprsme.PartitionManager do
# Validate partition name to prevent SQL injection
validate_partition_name!(name)
Repo.query!("DROP TABLE IF EXISTS #{quote_identifier(name)}")
_ = Repo.query!("DROP TABLE IF EXISTS #{quote_identifier(name)}")
Logger.debug("Dropped partition #{name}")
name
end

View file

@ -26,7 +26,7 @@ defmodule Aprsme.PostgresNotifier do
@impl true
def handle_info({:notification, _conn, _pid, @event_channel, payload}, state) do
Phoenix.PubSub.broadcast(Aprsme.PubSub, @event_topic, {:postgres_notify, payload})
_ = Phoenix.PubSub.broadcast(Aprsme.PubSub, @event_topic, {:postgres_notify, payload})
{:noreply, state}
end

View file

@ -25,7 +25,7 @@ defmodule Aprsme.RegexCache do
@impl true
def init(_) do
# Table stores {pattern, regex, last_access_counter}
:ets.new(@table_name, [:set, :protected, :named_table, read_concurrency: true])
_table = :ets.new(@table_name, [:set, :protected, :named_table, read_concurrency: true])
{:ok, %{counter: 0}}
end

View file

@ -64,7 +64,7 @@ defmodule Aprsme.Release do
end
def rollback(repo, version) do
load_app()
_ = load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
@ -104,24 +104,25 @@ defmodule Aprsme.Release do
# Notify about deployment after a short delay to ensure PubSub is started
# In k8s, this will notify all connected clients about the new deployment
if System.get_env("DEPLOYED_AT") do
Task.start(fn ->
# Wait for application to start
Process.sleep(10_000)
_ =
if System.get_env("DEPLOYED_AT") do
Task.start(fn ->
# Wait for application to start
Process.sleep(10_000)
try do
require Logger
Aprsme.DeploymentNotifier.notify_deployment(deployed_at)
Logger.info("Deployment notification sent for timestamp: #{deployed_at}")
rescue
error ->
try do
require Logger
Logger.warning("Failed to send deployment notification: #{inspect(error)}")
end
end)
end
_ = Aprsme.DeploymentNotifier.notify_deployment(deployed_at)
Logger.info("Deployment notification sent for timestamp: #{deployed_at}")
rescue
error ->
require Logger
Logger.warning("Failed to send deployment notification: #{inspect(error)}")
end
end)
end
deployed_at
end
@ -158,7 +159,7 @@ defmodule Aprsme.Release do
Aprsme.Repo,
fn repo ->
timeout_seconds = div(timeout, 1000)
SQL.query!(repo, "SET statement_timeout = '#{timeout_seconds}s'", [])
_ = SQL.query!(repo, "SET statement_timeout = '#{timeout_seconds}s'", [])
Ecto.Migrator.run(repo, :up, all: true)
end,
timeout: timeout

View file

@ -122,7 +122,7 @@ defmodule Aprsme.ShutdownHandler do
log_connection_stats()
# Terminate remaining connections
terminate_remaining_connections()
_ = terminate_remaining_connections()
# Exit the application
System.stop(0)

View file

@ -190,10 +190,11 @@ defmodule Aprsme.SpatialPubSub do
|> Enum.map(& &1.topic)
# Use dedicated broadcast task supervisor for better performance
Aprsme.BroadcastTaskSupervisor.broadcast_async(
topics,
{:spatial_packet, packet}
)
_ =
Aprsme.BroadcastTaskSupervisor.broadcast_async(
topics,
{:spatial_packet, packet}
)
# Update statistics (immediately return control to GenServer)
state =

View file

@ -69,10 +69,10 @@ defmodule Aprsme.StreamingPacketsPubSub do
@impl true
def init(_opts) do
# Create ETS table for fast lookups
:ets.new(@table_name, [:set, :protected, :named_table, read_concurrency: true])
_ = :ets.new(@table_name, [:set, :protected, :named_table, read_concurrency: true])
# Monitor subscribers for cleanup
Process.flag(:trap_exit, true)
_ = Process.flag(:trap_exit, true)
# pid => monitor_ref — track monitors to demonitor on unsubscribe/update
{:ok, %{monitors: %{}}}
@ -105,7 +105,7 @@ defmodule Aprsme.StreamingPacketsPubSub do
@impl true
def handle_cast({:broadcast, packet}, state) do
maybe_broadcast_packet(packet)
_ = maybe_broadcast_packet(packet)
{:noreply, state}
end

View file

@ -346,7 +346,7 @@ defmodule AprsmeWeb.AprsSymbol do
end
# Helper function to safely extract a value from a packet or data_extended map
@spec get_packet_field(map(), atom(), any()) :: any()
@spec get_packet_field(map(), atom(), String.t()) :: String.t()
defp get_packet_field(packet, field, default) do
data_extended = Map.get(packet, :data_extended, Map.get(packet, "data_extended", %{})) || %{}

View file

@ -105,8 +105,8 @@ defmodule AprsmeWeb.Api.V1.WeatherController do
end
end
@spec validate_optional_integer(map(), String.t(), integer()) ::
{:ok, integer()} | {:error, :bad_request, String.t()}
@spec validate_optional_integer(map(), <<_::40>>, 6 | 50) ::
{:ok, integer()} | {:error, :bad_request, <<_::64, _::_*8>>}
defp validate_optional_integer(params, key, default) do
case Map.get(params, key) do
nil ->

View file

@ -34,7 +34,7 @@ defmodule AprsmeWeb.PageController do
# Normal health check
db_healthy =
try do
Aprsme.Repo.query!("SELECT 1")
_ = Aprsme.Repo.query!("SELECT 1")
true
rescue
_ -> false

View file

@ -11,7 +11,7 @@ defmodule AprsmeWeb.BadPacketsLive.Index do
def mount(_params, _session, socket) do
if connected?(socket) do
# Subscribe to Postgres notifications for bad packets
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_events")
:ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_events")
# Load initial bad packets
bad_packets = fetch_bad_packets()
# Extra safeguard to ensure we never show more than 100

View file

@ -63,9 +63,10 @@ defmodule AprsmeWeb.InfoLive.Show do
normalized_callsign = Callsign.normalize(callsign)
# Subscribe to callsign-specific topic for live updates
if connected?(socket) do
Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets:#{normalized_callsign}")
end
_ =
if connected?(socket) do
Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets:#{normalized_callsign}")
end
packet = get_latest_packet(normalized_callsign)
packet = if packet, do: SharedPacketHandler.enrich_with_device_info(packet)

View file

@ -15,9 +15,10 @@ defmodule AprsmeWeb.LocaleHook do
get_locale_from_session(session) || "en"
end
if locale do
Gettext.put_locale(AprsmeWeb.Gettext, locale)
end
_ =
if locale do
Gettext.put_locale(AprsmeWeb.Gettext, locale)
end
# Set map_page assign based on the view module
map_page = map_page?(socket, params)

View file

@ -86,16 +86,16 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
@doc """
Build packet data with default locale.
"""
@spec build_packet_data(map(), boolean()) :: map() | nil
def build_packet_data(packet, is_most_recent_for_callsign) do
@spec build_packet_data(map(), boolean()) :: nil | %{<<_::16, _::_*8>> => any()}
def build_packet_data(packet, is_most_recent_for_callsign) when is_boolean(is_most_recent_for_callsign) do
build_packet_data(packet, is_most_recent_for_callsign, "en")
end
@doc """
Build packet data with default parameters.
"""
@spec build_packet_data(map()) :: map() | nil
def build_packet_data(packet) do
@spec build_packet_data(map()) :: nil | %{<<_::16, _::_*8>> => any()}
def build_packet_data(packet) when is_map(packet) do
build_packet_data(packet, false, "en")
end
@ -161,7 +161,7 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
end
# Validate coordinates are numeric and within valid ranges
@spec valid_coordinates?(any(), any()) :: boolean()
@spec valid_coordinates?(number() | nil, number() | nil) :: boolean()
defp valid_coordinates?(lat, lon) when is_number(lat) and is_number(lon) do
# Range checks ensure finite values; infinity would be outside these ranges
lat >= -90 and lat <= 90 and lon >= -180 and lon <= 180
@ -363,7 +363,20 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
end
end
@spec build_standard_popup_html(map(), float(), float()) :: String.t()
@spec build_standard_popup_html(
%{
:callsign => binary(),
:callsign_group => binary(),
:comment => any(),
:is_weather_packet => boolean(),
:safe_data_extended => any(),
:symbol_code => any(),
:symbol_table_id => any(),
:timestamp => binary()
},
float(),
float()
) :: binary()
defp build_standard_popup_html(packet_info, _lat, _lon) do
timestamp_dt = TimeHelpers.to_datetime(packet_info.timestamp)
cache_buster = System.system_time(:millisecond)
@ -435,7 +448,22 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
|> IO.iodata_to_binary()
end
@spec build_packet_result(map(), map(), float(), float(), String.t()) :: map()
@spec build_packet_result(
map(),
%{
:callsign => binary(),
:callsign_group => binary(),
:comment => any(),
:is_weather_packet => boolean(),
:safe_data_extended => any(),
:symbol_code => nil | binary(),
:symbol_table_id => nil | binary(),
:timestamp => binary()
},
float(),
float(),
binary()
) :: %{<<_::16, _::_*8>> => any()}
defp build_packet_result(packet, packet_info, lat, lon, popup) do
# Generate unique ID for live packets to prevent conflicts
packet_id =
@ -479,7 +507,11 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
# Utility functions for packet field access
@spec get_packet_field(map(), atom() | String.t(), any()) :: any()
@spec get_packet_field(
map(),
:base_callsign | :comment | :data_type | :path | :ssid | :symbol_code | :symbol_table_id,
nil | binary()
) :: any()
defp get_packet_field(packet, field, default) do
SharedPacketUtils.get_packet_field(packet, field, default)
end
@ -489,7 +521,7 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
SharedPacketUtils.get_timestamp(packet)
end
@spec to_float(any()) :: float()
@spec to_float(number() | nil) :: float()
defp to_float(value) do
ParamUtils.to_float(value)
end

View file

@ -384,13 +384,13 @@ defmodule AprsmeWeb.MapLive.HistoricalLoader do
# Very zoomed out - smaller batches, more of them
defp get_loading_params_for_zoom(_), do: {50, 5}
@spec calculate_batch_size_for_zoom(integer()) :: integer()
@spec calculate_batch_size_for_zoom(integer()) :: 50 | 75 | 100 | 500
defp calculate_batch_size_for_zoom(zoom) do
{batch_size, _} = get_loading_params_for_zoom(zoom)
batch_size
end
@spec calculate_batch_count_for_zoom(integer()) :: integer()
@spec calculate_batch_count_for_zoom(integer()) :: 2 | 3 | 4 | 5
defp calculate_batch_count_for_zoom(zoom) do
{_, batch_count} = get_loading_params_for_zoom(zoom)
batch_count
@ -437,10 +437,11 @@ defmodule AprsmeWeb.MapLive.HistoricalLoader do
# Limit to prevent too many queries
|> Enum.take(20)
if Enum.any?(rf_path_stations) do
# Schedule loading of RF path station packets
Process.send_after(self(), {:load_rf_path_station_packets, rf_path_stations}, 100)
end
_ =
if Enum.any?(rf_path_stations) do
# Schedule loading of RF path station packets
Process.send_after(self(), {:load_rf_path_station_packets, rf_path_stations}, 100)
end
socket
end

View file

@ -114,11 +114,12 @@ defmodule AprsmeWeb.MapLive.Index do
client_id = "liveview_#{:erlang.phash2(self())}"
# Register with connection monitor
if Application.get_env(:aprsme, :cluster_enabled, false) do
Aprsme.ConnectionMonitor.register_connection()
# Subscribe to drain events for this node
Phoenix.PubSub.subscribe(Aprsme.PubSub, "connection:drain:#{Node.self()}")
end
_ =
if Application.get_env(:aprsme, :cluster_enabled, false) do
_ = Aprsme.ConnectionMonitor.register_connection()
# Subscribe to drain events for this node
Phoenix.PubSub.subscribe(Aprsme.PubSub, "connection:drain:#{Node.self()}")
end
# Register with spatial PubSub (will get viewport later)
# Start with a default viewport that will be updated when we get actual bounds
@ -126,20 +127,20 @@ defmodule AprsmeWeb.MapLive.Index do
{:ok, spatial_topic} = Aprsme.SpatialPubSub.register_viewport(client_id, default_bounds)
# Subscribe to the spatial topic for this client
Phoenix.PubSub.subscribe(Aprsme.PubSub, spatial_topic)
:ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, spatial_topic)
# Still subscribe to bad packets (they don't have location)
Phoenix.PubSub.subscribe(Aprsme.PubSub, "bad_packets")
:ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "bad_packets")
# Subscribe to deployment events
Phoenix.PubSub.subscribe(Aprsme.PubSub, "deployment_events")
:ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "deployment_events")
# Subscribe to StreamingPacketsPubSub with initial bounds
Aprsme.StreamingPacketsPubSub.subscribe_to_bounds(self(), default_bounds)
_ = Aprsme.StreamingPacketsPubSub.subscribe_to_bounds(self(), default_bounds)
Process.send_after(self(), :cleanup_old_packets, 60_000)
_ = Process.send_after(self(), :cleanup_old_packets, 60_000)
# Schedule UI update timer to refresh "time ago" display every 30 seconds
Process.send_after(self(), :update_time_display, 30_000)
_ = Process.send_after(self(), :update_time_display, 30_000)
socket
|> assign(:spatial_client_id, client_id)
@ -155,7 +156,7 @@ defmodule AprsmeWeb.MapLive.Index do
end
defp do_setup_additional_subscriptions(socket, true) do
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
:ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
socket
end
@ -410,7 +411,7 @@ defmodule AprsmeWeb.MapLive.Index do
# Cancel any pending hover end timer
socket =
if socket.assigns[:hover_end_timer] do
Process.cancel_timer(socket.assigns.hover_end_timer)
_ = Process.cancel_timer(socket.assigns.hover_end_timer)
assign(socket, hover_end_timer: nil)
else
socket
@ -833,33 +834,36 @@ defmodule AprsmeWeb.MapLive.Index do
def handle_info({:postgres_packet, packet}, socket) do
# Add packet to batcher instead of processing immediately
if socket.assigns[:batcher_pid] do
PacketBatcher.add_packet(socket.assigns.batcher_pid, packet)
else
handle_info_postgres_packet(packet, socket)
end
_ =
if socket.assigns[:batcher_pid] do
PacketBatcher.add_packet(socket.assigns.batcher_pid, packet)
else
handle_info_postgres_packet(packet, socket)
end
{:noreply, socket}
end
def handle_info({:spatial_packet, packet}, socket) do
# Add packet to batcher instead of processing immediately
if socket.assigns[:batcher_pid] do
PacketBatcher.add_packet(socket.assigns.batcher_pid, packet)
else
handle_info_postgres_packet(packet, socket)
end
_ =
if socket.assigns[:batcher_pid] do
PacketBatcher.add_packet(socket.assigns.batcher_pid, packet)
else
handle_info_postgres_packet(packet, socket)
end
{:noreply, socket}
end
def handle_info({:streaming_packet, packet}, socket) do
# Add packet to batcher instead of processing immediately
if socket.assigns[:batcher_pid] do
PacketBatcher.add_packet(socket.assigns.batcher_pid, packet)
else
handle_info_postgres_packet(packet, socket)
end
_ =
if socket.assigns[:batcher_pid] do
PacketBatcher.add_packet(socket.assigns.batcher_pid, packet)
else
handle_info_postgres_packet(packet, socket)
end
{:noreply, socket}
end
@ -1860,39 +1864,45 @@ defmodule AprsmeWeb.MapLive.Index do
@impl true
def terminate(_reason, socket) do
# Unregister from connection monitor
if Application.get_env(:aprsme, :cluster_enabled, false) and
not socket.assigns[:connection_draining] do
Aprsme.ConnectionMonitor.unregister_connection()
end
_ =
if Application.get_env(:aprsme, :cluster_enabled, false) and
not socket.assigns[:connection_draining] do
Aprsme.ConnectionMonitor.unregister_connection()
end
# Cleanup spatial registration if we have a client ID
if socket.assigns[:spatial_client_id] do
Aprsme.SpatialPubSub.unregister_client(socket.assigns.spatial_client_id)
end
_ =
if socket.assigns[:spatial_client_id] do
Aprsme.SpatialPubSub.unregister_client(socket.assigns.spatial_client_id)
end
# Unsubscribe from StreamingPacketsPubSub
Aprsme.StreamingPacketsPubSub.unsubscribe(self())
_ = Aprsme.StreamingPacketsPubSub.unsubscribe(self())
# Unsubscribe from PubSub topic subscribed in mount
Phoenix.PubSub.unsubscribe(Aprsme.PubSub, "postgres:aprsme_packets")
_ = Phoenix.PubSub.unsubscribe(Aprsme.PubSub, "postgres:aprsme_packets")
_ = if socket.assigns.buffer_timer, do: Process.cancel_timer(socket.assigns.buffer_timer)
if socket.assigns.buffer_timer, do: Process.cancel_timer(socket.assigns.buffer_timer)
# Clean up any pending bounds update timer
if socket.assigns[:bounds_update_timer] do
Process.cancel_timer(socket.assigns.bounds_update_timer)
end
_ =
if socket.assigns[:bounds_update_timer] do
Process.cancel_timer(socket.assigns.bounds_update_timer)
end
# Clean up pending hover-end timer — set when the mouse leaves a marker
# and only cleared when it re-enters; if the LV terminates between the two,
# the timer would otherwise outlive the socket.
if socket.assigns[:hover_end_timer] do
Process.cancel_timer(socket.assigns.hover_end_timer)
end
_ =
if socket.assigns[:hover_end_timer] do
Process.cancel_timer(socket.assigns.hover_end_timer)
end
# Clean up any pending batch tasks
if socket.assigns[:pending_batch_tasks] do
Enum.each(socket.assigns.pending_batch_tasks, &Process.cancel_timer/1)
end
_ =
if socket.assigns[:pending_batch_tasks] do
Enum.each(socket.assigns.pending_batch_tasks, &Process.cancel_timer/1)
end
:ok
end

View file

@ -101,7 +101,7 @@ defmodule AprsmeWeb.MapLive.PacketBatcher do
defp cancel_timer(%{timer_ref: nil} = state), do: state
defp cancel_timer(%{timer_ref: ref} = state) do
Process.cancel_timer(ref)
_ = Process.cancel_timer(ref)
%{state | timer_ref: nil}
end

View file

@ -13,7 +13,7 @@ defmodule AprsmeWeb.MapLive.UrlParams do
Parse map state from URL parameters.
Returns {map_center, zoom} tuple with validated coordinates.
"""
@spec parse_map_params(map()) :: {map(), integer()}
@spec parse_map_params(map()) :: {%{lat: float(), lng: float()}, integer()}
def parse_map_params(params) do
lat = parse_latitude(Map.get(params, "lat"))
lng = parse_longitude(Map.get(params, "lng"))
@ -72,12 +72,12 @@ defmodule AprsmeWeb.MapLive.UrlParams do
@doc """
Get default map center coordinates.
"""
@spec default_center() :: map()
@spec default_center() :: %{lat: float(), lng: float()}
def default_center, do: @default_center
@doc """
Get default zoom level.
"""
@spec default_zoom() :: integer()
@spec default_zoom() :: 5
def default_zoom, do: @default_zoom
end

View file

@ -18,9 +18,10 @@ defmodule AprsmeWeb.PacketsLive.CallsignView do
if Callsign.valid?(normalized_callsign) do
# Subscribe to live packet updates for this callsign only
if connected?(socket) do
Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets:#{normalized_callsign}")
end
_ =
if connected?(socket) do
Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets:#{normalized_callsign}")
end
# Get stored packets for this callsign (up to 100)
stored_packets = get_stored_packets(normalized_callsign, 100)

View file

@ -7,9 +7,10 @@ defmodule AprsmeWeb.PacketsLive.Index do
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
end
_ =
if connected?(socket) do
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
end
{:ok, assign(socket, :packets, [])}
end

View file

@ -48,7 +48,7 @@ defmodule AprsmeWeb.Live.Shared.PacketUtils do
@doc """
Parse trail duration with validation and bounds checking.
"""
@spec parse_trail_duration(binary() | any()) :: integer()
@spec parse_trail_duration(any()) :: 1 | 6 | 12 | 24 | 48 | 168
def parse_trail_duration(duration) when is_binary(duration) do
case Integer.parse(duration) do
{hours, ""} when hours in [1, 6, 12, 24, 48, 168] ->
@ -65,7 +65,7 @@ defmodule AprsmeWeb.Live.Shared.PacketUtils do
@doc """
Parse historical hours with validation.
"""
@spec parse_historical_hours(binary() | any()) :: integer()
@spec parse_historical_hours(any()) :: 1 | 3 | 6 | 12 | 24
def parse_historical_hours(hours) when is_binary(hours) do
case Integer.parse(hours) do
{h, ""} when h in [1, 3, 6, 12, 24] ->

View file

@ -25,13 +25,14 @@ defmodule AprsmeWeb.StatusLive.Index do
loading: false
)
if connected?(socket) do
# Subscribe to status updates via PubSub
Phoenix.PubSub.subscribe(Aprsme.PubSub, "aprs_status")
_ =
if connected?(socket) do
# Subscribe to status updates via PubSub
:ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "aprs_status")
# Schedule the first refresh with a slight delay
Process.send_after(self(), :refresh_status, 500)
end
# Schedule the first refresh with a slight delay
Process.send_after(self(), :refresh_status, 500)
end
{:ok, socket}
end
@ -41,31 +42,32 @@ defmodule AprsmeWeb.StatusLive.Index do
# Refresh status asynchronously using supervised task
self_pid = self()
Task.Supervisor.start_child(Aprsme.BroadcastTaskSupervisor, fn ->
try do
status = get_aprs_status()
send(self_pid, {:status_updated, status})
rescue
error ->
Logger.error("Failed to refresh APRS status: #{inspect(error)}")
# Send empty/default status on error
send(
self_pid,
{:status_updated,
%{
connected: false,
uptime_seconds: 0,
data_rate: 0,
packet_stats: %{
packets_per_second: 0,
last_packet_at: nil
},
stored_packet_count: 0,
error: "Failed to fetch status"
}}
)
end
end)
_ =
Task.Supervisor.start_child(Aprsme.BroadcastTaskSupervisor, fn ->
try do
status = get_aprs_status()
send(self_pid, {:status_updated, status})
rescue
error ->
Logger.error("Failed to refresh APRS status: #{inspect(error)}")
# Send empty/default status on error
send(
self_pid,
{:status_updated,
%{
connected: false,
uptime_seconds: 0,
data_rate: 0,
packet_stats: %{
packets_per_second: 0,
last_packet_at: nil
},
stored_packet_count: 0,
error: "Failed to fetch status"
}}
)
end
end)
# Schedule next refresh
schedule_refresh()

View file

@ -86,7 +86,7 @@ defmodule AprsmeWeb.Plugs.HealthCheck do
end
defp check_pubsub do
Phoenix.PubSub.broadcast(Aprsme.PubSub, "health_check", :ping)
_ = Phoenix.PubSub.broadcast(Aprsme.PubSub, "health_check", :ping)
:ok
rescue
_ -> {:error, "PubSub check failed"}

View file

@ -10,7 +10,7 @@ defmodule AprsmeWeb.Plugs.SetLocale do
def call(conn, _opts) do
locale = get_locale_from_header(conn) || "en"
# Set the backend's locale for the current process
Gettext.put_locale(AprsmeWeb.Gettext, locale)
_ = Gettext.put_locale(AprsmeWeb.Gettext, locale)
# Store locale in session for LiveView to access
conn = put_session(conn, :locale, locale)
conn

View file

@ -79,9 +79,10 @@ defmodule AprsmeWeb.UserAuth do
user_token = get_session(conn, :user_token)
user_token && Accounts.delete_user_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do
AprsmeWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
_ =
if live_socket_id = get_session(conn, :live_socket_id) do
AprsmeWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session()

View file

@ -15,6 +15,7 @@ defmodule Aprsme.MixProject do
listeners: [Phoenix.CodeReloader],
dialyzer: [
ignore_warnings: ".dialyzer_ignore.exs",
flags: [:error_handling, :underspecs, :unmatched_returns],
plt_file:
{:no_warn,
"_build/dev/dialyxir_erlang-#{:erlang.system_info(:otp_release)}_elixir-#{System.version()}_deps-dev.plt"}