diff --git a/lib/mob/theme.ex b/lib/mob/theme.ex index d783d81..42d51cc 100644 --- a/lib/mob/theme.ex +++ b/lib/mob/theme.ex @@ -1,4 +1,6 @@ defmodule Mob.Theme do + @nif_status {__MODULE__, :nif_status} + @moduledoc """ Design token system for Mob apps. @@ -301,19 +303,11 @@ defmodule Mob.Theme do json = IO.iodata_to_binary(:json.encode(stringify_keys(payload))) - try do - :mob_nif.set_theme(json) - rescue - _ -> :ok - catch - _, _ -> :ok - end + nif_call(:ok, fn -> :mob_nif.set_theme(json) end) end defp safe_platform do - :mob_nif.platform() - rescue - _ in [UndefinedFunctionError, ErlangError] -> :host + nif_call(:host, fn -> :mob_nif.platform() end) end defp resolved_font_fallback(theme, platform) do @@ -348,13 +342,60 @@ defmodule Mob.Theme do """ @spec color_scheme() :: :light | :dark def color_scheme do - case :mob_nif.color_scheme() do - :dark -> :dark - _ -> :light + nif_call(:light, fn -> + if :mob_nif.color_scheme() == :dark, do: :dark, else: :light + end) + end + + defp nif_call(fallback, call) do + case :persistent_term.get(@nif_status, :unknown) do + :available -> invoke_nif(fallback, call, false) + :unavailable -> recover_nif(fallback, call) + :unknown -> probe_nif(fallback, call) end + end + + defp invoke_nif(fallback, call, mark_available?) do + result = call.() + if mark_available?, do: :persistent_term.put(@nif_status, :available) + result rescue - # NIF not loaded (host BEAM), wrong arity, or platform doesn't implement - _ -> :light + _error -> mark_nif_unavailable(fallback) + catch + _kind, _reason -> mark_nif_unavailable(fallback) + end + + defp mark_nif_unavailable(fallback) do + :persistent_term.put(@nif_status, :unavailable) + fallback + end + + defp recover_nif(fallback, call) do + if :code.is_loaded(:mob_nif) == false, do: fallback, else: probe_nif(fallback, call) + end + + # A failed on_load purges mob_nif, so every Theme caller would otherwise + # retry the same failing load. The first probe is serialised across callers; + # a later successful native code load makes the module visible and re-enables + # all native theme calls. + defp probe_nif(fallback, call) do + :global.trans( + {@nif_status, self()}, + fn -> + case :persistent_term.get(@nif_status, :unknown) do + :available -> invoke_nif(fallback, call, false) + :unavailable -> recover_nif_without_lock(fallback, call) + :unknown -> invoke_nif(fallback, call, true) + end + end, + [node()] + ) + end + + defp recover_nif_without_lock(fallback, call) do + if :code.is_loaded(:mob_nif) == false, + do: fallback, + else: invoke_nif(fallback, call, true) end # ── Token maps (used by Mob.Renderer) ───────────────────────────────────── diff --git a/test/mob/theme_host_test.exs b/test/mob/theme_host_test.exs new file mode 100644 index 0000000..02faea8 --- /dev/null +++ b/test/mob/theme_host_test.exs @@ -0,0 +1,93 @@ +defmodule Mob.ThemeHostTest do + use ExUnit.Case, async: false + + test "all host theme callers share exactly one failed NIF probe" do + script = ~S''' + Application.ensure_all_started(:ex_unit) + import ExUnit.CaptureLog + + key = {Mob.Theme, :nif_status} + :persistent_term.erase(key) + :unknown = :persistent_term.get(key, :unknown) + + calls = + List.duplicate(&Mob.Theme.color_scheme/0, 6) ++ + List.duplicate(fn -> Mob.Theme.set(Mob.Theme.default()) end, 6) + + log = + capture_log(fn -> + tasks = Enum.map(calls, fn call -> Task.async(fn -> receive do: (:go -> call.()) end) end) + Enum.each(tasks, &send(&1.pid, :go)) + results = Enum.map(tasks, &Task.await/1) + true = Enum.all?(results, &(&1 in [:light, :ok])) + Process.sleep(100) + end) + + 1 = length(:binary.matches(log, "The on_load function for module mob_nif returned")) + :unavailable = :persistent_term.get(key) + :persistent_term.erase(key) + :unknown = :persistent_term.get(key, :unknown) + IO.puts("shared_probe_ok") + ''' + + assert_isolated_success(script, "shared_probe_ok") + end + + test "theme NIF availability recovers across module load and unload" do + script = ~S''' + Application.ensure_all_started(:ex_unit) + import ExUnit.CaptureLog + + key = {Mob.Theme, :nif_status} + :persistent_term.erase(key) + :unknown = :persistent_term.get(key, :unknown) + + fake_nif = """ + defmodule :mob_nif do + def platform, do: :ios + def set_theme(_json), do: :ok + def color_scheme, do: :dark + end + """ + + capture_log(fn -> + :light = Mob.Theme.color_scheme() + :unavailable = :persistent_term.get(key) + + Code.compile_string(fake_nif) + :dark = Mob.Theme.color_scheme() + :ok = Mob.Theme.set(Mob.Theme.default()) + :available = :persistent_term.get(key) + + :code.delete(:mob_nif) + :code.purge(:mob_nif) + false = :code.is_loaded(:mob_nif) + + :light = Mob.Theme.color_scheme() + :unavailable = :persistent_term.get(key) + + Code.compile_string(fake_nif) + :dark = Mob.Theme.color_scheme() + :available = :persistent_term.get(key) + Process.sleep(100) + end) + + :persistent_term.erase(key) + :unknown = :persistent_term.get(key, :unknown) + IO.puts("recovery_ok") + ''' + + assert_isolated_success(script, "recovery_ok") + end + + defp assert_isolated_success(script, marker) do + {output, status} = + System.cmd("mix", ["run", "--no-compile", "--no-start", "-e", script], + env: [{"MIX_ENV", "test"}], + stderr_to_stdout: true + ) + + assert status == 0, output + assert output =~ marker + end +end