fix(map): harden :preload_forecast against slow NFS + LiveStash crash

Two user-visible crashes in MapLive seen on prod pods today:

1. `:preload_forecast` handle_info timed out a Task.async_stream subtask
   after 10 s and the `Enum.map(fn {:ok, h} -> h end)` match cascaded
   the `:exit, :timeout` into the LiveView process, terminating the
   session. Adds `on_timeout: :kill_task` and flat-maps only :ok
   elements — a slow NFS forecast hour now drops from the preload
   payload instead of killing the LV.

2. `LiveStash.stash/1` crashed with `** (ArgumentError) errors were
   found at the given arguments: 2nd argument: not a valid match
   specification` inside `:ets.select_replace/2` on OTP 28. The dep
   (live_stash 0.2.0) has a latent bug against the tightened OTP 28
   match-spec validator. Both call sites (MapLive.select_band,
   SubmitLive.switch_tab) now route through a new
   `MicrowavepropWeb.LiveStashGuard.stash/1` wrapper that rescues
   ArgumentError, logs a warning, and returns the socket unchanged.
   Stash is a convenience for reconnect state — failing it does not
   need to kill the user's session.

Drop the guard module once the upstream match-spec fix lands.
This commit is contained in:
Graham McIntire 2026-04-24 10:15:19 -05:00
parent c514626a62
commit d88bdd70a8
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 46 additions and 4 deletions

View file

@ -0,0 +1,34 @@
defmodule MicrowavepropWeb.LiveStashGuard do
@moduledoc """
Tiny wrapper around `LiveStash.stash/1` that swallows the
`ArgumentError: not a valid match specification` crash LiveStash 0.2.0
raises under OTP 28.
The stash is a convenience for state persistence across reconnects;
a failure is not fatal to the user's session. We log and return the
socket unchanged instead of letting the LiveView process terminate
mid-`handle_event`.
Remove this module and inline `LiveStash.stash/1` calls once the
upstream match-spec fix lands in the `live_stash` hex package.
"""
alias Phoenix.LiveView.Socket
require Logger
@doc """
Best-effort wrapper around `LiveStash.stash/1`.
Returns the (possibly unchanged) socket no matter what the adapter
raises.
"""
@spec stash(Socket.t()) :: Socket.t()
def stash(socket) do
LiveStash.stash(socket)
rescue
e in ArgumentError ->
Logger.warning("LiveStashGuard: stash skipped (#{Exception.message(e)})")
socket
end
end

View file

@ -232,7 +232,7 @@ defmodule MicrowavepropWeb.MapLive do
socket
|> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time)
|> assign(:tracking_now?, tracking_now?(selected_time, valid_times, DateTime.utc_now()))
|> LiveStash.stash()
|> MicrowavepropWeb.LiveStashGuard.stash()
|> push_event("update_scores", %{scores: Propagation.pack_scores(scores)})
|> push_event("update_band_info", %{band_info: band_info(band)})
|> push_timeline()
@ -412,6 +412,10 @@ defmodule MicrowavepropWeb.MapLive do
# since the JS hook expects `hours` in valid_time order.
forecast_times = Enum.reject(socket.assigns.valid_times, &(selected && DateTime.compare(&1, selected) == :eq))
# `on_timeout: :kill_task` + explicit filter on :ok keeps a slow NFS
# read from cascading the whole stream's :timeout exit up into the
# LiveView process. A timed-out forecast hour just drops from the
# preload payload; the client re-requests it on demand.
hours =
forecast_times
|> Task.async_stream(
@ -423,9 +427,13 @@ defmodule MicrowavepropWeb.MapLive do
end,
max_concurrency: 4,
ordered: true,
timeout: 10_000
timeout: 10_000,
on_timeout: :kill_task
)
|> Enum.map(fn {:ok, h} -> h end)
|> Enum.flat_map(fn
{:ok, h} -> [h]
{:exit, _} -> []
end)
{:noreply,
socket

View file

@ -57,7 +57,7 @@ defmodule MicrowavepropWeb.SubmitLive do
@impl true
def handle_event("switch_tab", %{"tab" => tab}, socket) do
socket = assign(socket, active_tab: String.to_existing_atom(tab))
{:noreply, LiveStash.stash(socket)}
{:noreply, MicrowavepropWeb.LiveStashGuard.stash(socket)}
end
def handle_event("validate", %{"contact" => contact_params}, socket) do