From d88bdd70a807d6d092bf6fb2d00a9186dd2e92ac Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 24 Apr 2026 10:15:19 -0500 Subject: [PATCH] fix(map): harden :preload_forecast against slow NFS + LiveStash crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../live/live_stash_guard.ex | 34 +++++++++++++++++++ lib/microwaveprop_web/live/map_live.ex | 14 ++++++-- lib/microwaveprop_web/live/submit_live.ex | 2 +- 3 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 lib/microwaveprop_web/live/live_stash_guard.ex diff --git a/lib/microwaveprop_web/live/live_stash_guard.ex b/lib/microwaveprop_web/live/live_stash_guard.ex new file mode 100644 index 00000000..e9c053a3 --- /dev/null +++ b/lib/microwaveprop_web/live/live_stash_guard.ex @@ -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 diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 0d53860b..5ea59b00 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -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 diff --git a/lib/microwaveprop_web/live/submit_live.ex b/lib/microwaveprop_web/live/submit_live.ex index 98edb3f2..66dfa1c1 100644 --- a/lib/microwaveprop_web/live/submit_live.ex +++ b/lib/microwaveprop_web/live/submit_live.ex @@ -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