Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 56 additions & 15 deletions lib/mob/theme.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
defmodule Mob.Theme do
@nif_status {__MODULE__, :nif_status}

@moduledoc """
Design token system for Mob apps.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) ─────────────────────────────────────
Expand Down
93 changes: 93 additions & 0 deletions test/mob/theme_host_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading