From 7946ada975b6d74bf2703185738190406154bcfa Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 30 Aug 2026 02:14:19 -0600 Subject: [PATCH 1/2] fix(theme): avoid repeated host NIF probes Cache NIF availability after the first serialized probe while allowing a later native code load to restore the native path. Bead: clarity-7mu --- lib/mob/theme.ex | 45 ++++++++++++++++++++++++++++++++---- test/mob/theme_host_test.exs | 20 ++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 test/mob/theme_host_test.exs diff --git a/lib/mob/theme.ex b/lib/mob/theme.ex index d783d81..b06b2cd 100644 --- a/lib/mob/theme.ex +++ b/lib/mob/theme.ex @@ -1,4 +1,6 @@ defmodule Mob.Theme do + @color_scheme_nif_status {__MODULE__, :color_scheme_nif_status} + @moduledoc """ Design token system for Mob apps. @@ -348,13 +350,46 @@ defmodule Mob.Theme do """ @spec color_scheme() :: :light | :dark def color_scheme do - case :mob_nif.color_scheme() do - :dark -> :dark - _ -> :light + case :persistent_term.get(@color_scheme_nif_status, :unknown) do + :available -> native_color_scheme(false) + :unavailable -> recover_color_scheme_nif() + :unknown -> probe_color_scheme_nif() end + end + + defp native_color_scheme(mark_available?) do + scheme = if :mob_nif.color_scheme() == :dark, do: :dark, else: :light + if mark_available?, do: :persistent_term.put(@color_scheme_nif_status, :available) + scheme rescue - # NIF not loaded (host BEAM), wrong arity, or platform doesn't implement - _ -> :light + _error in [UndefinedFunctionError, ErlangError] -> + :persistent_term.put(@color_scheme_nif_status, :unavailable) + :light + end + + defp recover_color_scheme_nif do + if :code.is_loaded(:mob_nif) == false, do: :light, else: probe_color_scheme_nif() + end + + # A failed on_load purges mob_nif, so every 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 it. + defp probe_color_scheme_nif do + :global.trans( + {@color_scheme_nif_status, self()}, + fn -> + case :persistent_term.get(@color_scheme_nif_status, :unknown) do + :available -> native_color_scheme(false) + :unavailable -> recover_color_scheme_nif_without_lock() + :unknown -> native_color_scheme(true) + end + end, + [node()] + ) + end + + defp recover_color_scheme_nif_without_lock do + if :code.is_loaded(:mob_nif) == false, do: :light, else: native_color_scheme(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..308a308 --- /dev/null +++ b/test/mob/theme_host_test.exs @@ -0,0 +1,20 @@ +defmodule Mob.ThemeHostTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureLog + + test "repeated host color-scheme reads do not repeatedly probe the unavailable NIF" do + log = + capture_log(fn -> + assert Enum.map(1..3, fn _ -> Mob.Theme.color_scheme() end) == [:light, :light, :light] + + tasks = Enum.map(1..10, fn _ -> Task.async(&Mob.Theme.color_scheme/0) end) + assert Enum.map(tasks, &Task.await/1) == List.duplicate(:light, 10) + + Process.sleep(100) + end) + + warnings = :binary.matches(log, "The on_load function for module mob_nif returned") + assert Enum.count(warnings) <= 1 + end +end From 3fd3df1d560cd831a550ce55347015f125cc7452 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 30 Aug 2026 02:37:51 -0600 Subject: [PATCH 2/2] fix(theme): share NIF availability across calls Route platform, theme updates, and appearance reads through one serialized availability state and isolate deterministic load recovery tests. Bead: clarity-7mu --- lib/mob/theme.ex | 76 ++++++++++++++++-------------- test/mob/theme_host_test.exs | 91 ++++++++++++++++++++++++++++++++---- 2 files changed, 123 insertions(+), 44 deletions(-) diff --git a/lib/mob/theme.ex b/lib/mob/theme.ex index b06b2cd..42d51cc 100644 --- a/lib/mob/theme.ex +++ b/lib/mob/theme.ex @@ -1,5 +1,5 @@ defmodule Mob.Theme do - @color_scheme_nif_status {__MODULE__, :color_scheme_nif_status} + @nif_status {__MODULE__, :nif_status} @moduledoc """ Design token system for Mob apps. @@ -303,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 @@ -350,46 +342,60 @@ defmodule Mob.Theme do """ @spec color_scheme() :: :light | :dark def color_scheme do - case :persistent_term.get(@color_scheme_nif_status, :unknown) do - :available -> native_color_scheme(false) - :unavailable -> recover_color_scheme_nif() - :unknown -> probe_color_scheme_nif() + 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 native_color_scheme(mark_available?) do - scheme = if :mob_nif.color_scheme() == :dark, do: :dark, else: :light - if mark_available?, do: :persistent_term.put(@color_scheme_nif_status, :available) - scheme + defp invoke_nif(fallback, call, mark_available?) do + result = call.() + if mark_available?, do: :persistent_term.put(@nif_status, :available) + result rescue - _error in [UndefinedFunctionError, ErlangError] -> - :persistent_term.put(@color_scheme_nif_status, :unavailable) - :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_color_scheme_nif do - if :code.is_loaded(:mob_nif) == false, do: :light, else: probe_color_scheme_nif() + 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 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 it. - defp probe_color_scheme_nif do + # 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( - {@color_scheme_nif_status, self()}, + {@nif_status, self()}, fn -> - case :persistent_term.get(@color_scheme_nif_status, :unknown) do - :available -> native_color_scheme(false) - :unavailable -> recover_color_scheme_nif_without_lock() - :unknown -> native_color_scheme(true) + 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_color_scheme_nif_without_lock do - if :code.is_loaded(:mob_nif) == false, do: :light, else: native_color_scheme(true) + 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 index 308a308..02faea8 100644 --- a/test/mob/theme_host_test.exs +++ b/test/mob/theme_host_test.exs @@ -1,20 +1,93 @@ defmodule Mob.ThemeHostTest do use ExUnit.Case, async: false - import ExUnit.CaptureLog + test "all host theme callers share exactly one failed NIF probe" do + script = ~S''' + Application.ensure_all_started(:ex_unit) + import ExUnit.CaptureLog - test "repeated host color-scheme reads do not repeatedly probe the unavailable NIF" do - log = - capture_log(fn -> - assert Enum.map(1..3, fn _ -> Mob.Theme.color_scheme() end) == [:light, :light, :light] + key = {Mob.Theme, :nif_status} + :persistent_term.erase(key) + :unknown = :persistent_term.get(key, :unknown) - tasks = Enum.map(1..10, fn _ -> Task.async(&Mob.Theme.color_scheme/0) end) - assert Enum.map(tasks, &Task.await/1) == List.duplicate(:light, 10) + 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) - warnings = :binary.matches(log, "The on_load function for module mob_nif returned") - assert Enum.count(warnings) <= 1 + 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