From 7765c0be04d3abd0cbe6c427e748da5b486566d7 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 15 Oct 2026 13:30:53 -0600 Subject: [PATCH 1/2] Generate the app driver table when an opt-in built-in NIF is enabled (MOB-394) A blank app with nx_tflite_mob in deps had no priv/generated/driver_tab_*, so it linked against mob's core table, which carries no TFLite entry: the template linked libtflite_nif.a, but NxTfliteMob.NIF never registered and loaded as `dlopen failed: library "libtflite_nif.so" not found`. regen_driver_tab! created an app table only when a plugin contributed NIFs. It now also creates one when any guarded built-in of StaticNifs.default_nifs/0 is enabled, judged by the same dep checks the native build uses to compile and link it (tflite_in_project?, nxeigen_in_project?, emlx_in_project?). NxEigen is covered by the same rule. Co-Authored-By: Claude Opus 5.5 --- lib/mob_dev/native_build.ex | 42 +++++++++++++--- test/mob_dev/native_build_test.exs | 78 ++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 8 deletions(-) diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 192e371..9e3bb46 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -5358,12 +5358,19 @@ defmodule MobDev.NativeBuild do # lacks: without an app-level table the plugin's `_nif_init` links yet # never registers, so `load_nif/2` falls back to dlopen and the NIF is # `:nif_not_loaded` on device. So when plugins contribute NIFs and the app has - # no table yet, create one (zig — the default format). Public for tests (and - # exercised on every `build_all`). + # no table yet, create one (zig — the default format). The same goes for the + # opt-in built-in NIFs (`mix mob.enable tflite | nxeigen | mlx`): their + # guarded entries are in `StaticNifs.default_nifs/0` but not in mob's core + # Android table, so a blank app with nx_tflite_mob linked TFLite yet never + # registered it (MOB-394). Public for tests (and exercised on every + # `build_all`). @spec regen_driver_tab!() :: :ok def regen_driver_tab! do formats = - __regen_formats__(__driver_tab_formats__(&File.exists?/1), __plugin_nifs_present__()) + __regen_formats__( + __driver_tab_formats__(&File.exists?/1), + __plugin_nifs_present__() or __enabled_builtin_nifs__() != [] + ) for fmt <- formats do paths = Mix.Tasks.Mob.RegenDriverTab.target_paths(fmt) @@ -5388,7 +5395,7 @@ defmodule MobDev.NativeBuild do existed? = File.exists?(path) File.mkdir_p!(Path.dirname(path)) File.write!(path, src) - verb = if existed?, do: "regenerated (was stale)", else: "created (plugin NIFs)" + verb = if existed?, do: "regenerated (was stale)", else: "created" IO.puts(" ✓ driver_tab #{verb}: #{path}") end end @@ -5404,16 +5411,35 @@ defmodule MobDev.NativeBuild do MobDev.Plugin.Merge.nifs(MobDev.Plugin.activated()) != [] end + @doc false + # The guarded built-in NIFs of `StaticNifs.default_nifs/0` this project + # enables, judged by the same dep checks the native build uses to decide + # whether to compile and link each one. `enabled?` maps a guard to that + # check. Public for tests. + @spec __enabled_builtin_nifs__((String.t() -> boolean())) :: [atom()] + def __enabled_builtin_nifs__(enabled? \\ &builtin_nif_enabled?/1) do + for %{guard: guard, module: module} <- MobDev.StaticNifs.default_nifs(), + enabled?.(guard), + do: module + end + + defp builtin_nif_enabled?("MOB_STATIC_TFLITE_NIF"), do: tflite_in_project?() + defp builtin_nif_enabled?("MOB_STATIC_NX_EIGEN_NIF"), do: nxeigen_in_project?() + defp builtin_nif_enabled?("MOB_STATIC_EMLX_NIF"), do: emlx_in_project?() + # sqlite3_nif is not opt-in: every iOS device build links it, and mob's core + # iOS table already carries its entry. + defp builtin_nif_enabled?(_guard), do: false + @doc false # Pure kernel: which driver_tab formats to (re)generate. An app with existing # tables keeps its format(s). An app with NONE normally generates nothing (it - # links against mob's core table) — UNLESS a NIF-bearing plugin is active, in - # which case it needs its own table (core + plugin entries), defaulting to - # zig. Public for tests. + # links against mob's core table) — UNLESS a NIF-bearing plugin or an opt-in + # built-in NIF is active, in which case it needs its own table (core + those + # entries), defaulting to zig. Public for tests. @spec __regen_formats__([:zig | :c], boolean()) :: [:zig | :c] def __regen_formats__([], true), do: [:zig] def __regen_formats__([], false), do: [] - def __regen_formats__(existing, _plugin_nifs?), do: existing + def __regen_formats__(existing, _needs_app_table?), do: existing defp warn_host_requirements! do case __host_requirements_warning__( diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index a1fd69b..de194ac 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -151,6 +151,84 @@ defmodule MobDev.NativeBuildTest do File.rm_rf!(dir) end end + + # MOB-394: a blank app with `mix mob.enable tflite` had no generated table, + # so it linked against mob's core Android table, which has no TFLite entry: + # NxTfliteMob loaded as :nif_not_loaded on the device. + test "creates a table with the TFLite entry for a host with nx_tflite_mob" do + host = regen_host([{:nx_tflite_mob, "nx_tflite_mob"}]) + + try do + android = File.read!(Path.join(host, "priv/generated/driver_tab_android.zig")) + ios = File.read!(Path.join(host, "priv/generated/driver_tab_ios.zig")) + + assert android =~ "extern fn tflite_nif_nif_init()" + assert ios =~ "extern fn tflite_nif_nif_init()" + after + File.rm_rf!(Path.dirname(host)) + end + end + + test "creates no table for a host with none of the opt-in NIFs" do + host = regen_host([{:other_dep, "other_dep"}]) + + try do + refute File.exists?(Path.join(host, "priv/generated")) + after + File.rm_rf!(Path.dirname(host)) + end + end + + test "__enabled_builtin_nifs__/1 maps each enabled guard to its built-in NIF" do + assert NativeBuild.__enabled_builtin_nifs__(fn _ -> false end) == [] + + assert NativeBuild.__enabled_builtin_nifs__(&(&1 == "MOB_STATIC_TFLITE_NIF")) == [ + :tflite_nif + ] + + assert NativeBuild.__enabled_builtin_nifs__(&(&1 == "MOB_STATIC_NX_EIGEN_NIF")) == [ + :nx_eigen + ] + end + + # A host Mix project (path deps on stub packages) with regen_driver_tab!/0 + # run inside it. Returns the host dir; its parent is the scratch root. + defp regen_host(deps) do + suffix = System.unique_integer([:positive]) + root = Path.join(System.tmp_dir!(), "mob_driver_tab_host_#{suffix}") + host = Path.join(root, "host") + File.mkdir_p!(host) + + dep_specs = + for {app, dir} <- deps do + dep_dir = Path.join(root, dir) + File.mkdir_p!(dep_dir) + + File.write!(Path.join(dep_dir, "mix.exs"), """ + defmodule Stub#{suffix}#{Macro.camelize(dir)}.MixProject do + use Mix.Project + def project, do: [app: #{inspect(app)}, version: "0.1.0"] + end + """) + + "{#{inspect(app)}, path: #{inspect(dep_dir)}}" + end + + File.write!(Path.join(host, "mix.exs"), """ + defmodule Host#{suffix}.MixProject do + use Mix.Project + def project, do: [app: :host, version: "0.1.0", deps: [#{Enum.join(dep_specs, ", ")}]] + end + """) + + Mix.ProjectStack.on_clean_slate(fn -> + Mix.Project.in_project(:"host#{suffix}", host, fn _ -> + assert :ok = NativeBuild.regen_driver_tab!() + end) + end) + + host + end end describe "__merge_android_manifest_components__/2" do From b10415ca6c3e476745659bc0622e36380944cd6b Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 15 Oct 2026 13:37:01 -0600 Subject: [PATCH 2/2] Leave EMLX out of the app-table trigger; test NxEigen end to end (MOB-394 review) mob's core iOS table already registers emlx_nif and EMLX is iOS-only, so a generated table added nothing and would make app build files from mob_new <=0.3.15 (no tflite_static option) fail to compile it. The NxEigen rule is now exercised by a host with an nx_eigen dep instead of an injected predicate; scratch hosts are removed on test exit even when an assertion fails. --- lib/mob_dev/native_build.ex | 41 +++++++++++++----------------- test/mob_dev/native_build_test.exs | 38 ++++++++++----------------- 2 files changed, 32 insertions(+), 47 deletions(-) diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 9e3bb46..d5959fb 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -5359,17 +5359,17 @@ defmodule MobDev.NativeBuild do # never registers, so `load_nif/2` falls back to dlopen and the NIF is # `:nif_not_loaded` on device. So when plugins contribute NIFs and the app has # no table yet, create one (zig — the default format). The same goes for the - # opt-in built-in NIFs (`mix mob.enable tflite | nxeigen | mlx`): their - # guarded entries are in `StaticNifs.default_nifs/0` but not in mob's core - # Android table, so a blank app with nx_tflite_mob linked TFLite yet never - # registered it (MOB-394). Public for tests (and exercised on every + # opt-in built-in NIFs mob's core tables lack (`mix mob.enable tflite | + # nxeigen`): their guarded entries are in `StaticNifs.default_nifs/0` but not + # in the core Android table, so a blank app with nx_tflite_mob linked TFLite + # yet never registered it (MOB-394). Public for tests (and exercised on every # `build_all`). @spec regen_driver_tab!() :: :ok def regen_driver_tab! do formats = __regen_formats__( __driver_tab_formats__(&File.exists?/1), - __plugin_nifs_present__() or __enabled_builtin_nifs__() != [] + __plugin_nifs_present__() or builtin_nifs_without_core_entry_enabled?() ) for fmt <- formats do @@ -5411,24 +5411,19 @@ defmodule MobDev.NativeBuild do MobDev.Plugin.Merge.nifs(MobDev.Plugin.activated()) != [] end - @doc false - # The guarded built-in NIFs of `StaticNifs.default_nifs/0` this project - # enables, judged by the same dep checks the native build uses to decide - # whether to compile and link each one. `enabled?` maps a guard to that - # check. Public for tests. - @spec __enabled_builtin_nifs__((String.t() -> boolean())) :: [atom()] - def __enabled_builtin_nifs__(enabled? \\ &builtin_nif_enabled?/1) do - for %{guard: guard, module: module} <- MobDev.StaticNifs.default_nifs(), - enabled?.(guard), - do: module - end - - defp builtin_nif_enabled?("MOB_STATIC_TFLITE_NIF"), do: tflite_in_project?() - defp builtin_nif_enabled?("MOB_STATIC_NX_EIGEN_NIF"), do: nxeigen_in_project?() - defp builtin_nif_enabled?("MOB_STATIC_EMLX_NIF"), do: emlx_in_project?() - # sqlite3_nif is not opt-in: every iOS device build links it, and mob's core - # iOS table already carries its entry. - defp builtin_nif_enabled?(_guard), do: false + # Is a guarded built-in NIF of `StaticNifs.default_nifs/0` that mob's core + # tables don't register enabled, judged by the same dep check the native + # build uses to compile and link it? EMLX is left out: it is iOS-only and + # mob's core iOS table already registers it, so a generated table gains + # nothing and would make old app build files (no `tflite_static` option) + # fail to compile it. sqlite3_nif is in the core iOS table too. + defp builtin_nifs_without_core_entry_enabled? do + Enum.any?(MobDev.StaticNifs.default_nifs(), fn + %{guard: "MOB_STATIC_TFLITE_NIF"} -> tflite_in_project?() + %{guard: "MOB_STATIC_NX_EIGEN_NIF"} -> nxeigen_in_project?() + _ -> false + end) + end @doc false # Pure kernel: which driver_tab formats to (re)generate. An app with existing diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index de194ac..9f9d7e6 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -158,44 +158,34 @@ defmodule MobDev.NativeBuildTest do test "creates a table with the TFLite entry for a host with nx_tflite_mob" do host = regen_host([{:nx_tflite_mob, "nx_tflite_mob"}]) - try do - android = File.read!(Path.join(host, "priv/generated/driver_tab_android.zig")) - ios = File.read!(Path.join(host, "priv/generated/driver_tab_ios.zig")) - - assert android =~ "extern fn tflite_nif_nif_init()" - assert ios =~ "extern fn tflite_nif_nif_init()" - after - File.rm_rf!(Path.dirname(host)) + for table <- ["driver_tab_android.zig", "driver_tab_ios.zig"] do + assert File.read!(Path.join(host, "priv/generated/#{table}")) =~ + "extern fn tflite_nif_nif_init()" end end - test "creates no table for a host with none of the opt-in NIFs" do - host = regen_host([{:other_dep, "other_dep"}]) + test "creates a table with the NxEigen entry for a host with nx_eigen" do + host = regen_host([{:nx_eigen, "nx_eigen"}]) - try do - refute File.exists?(Path.join(host, "priv/generated")) - after - File.rm_rf!(Path.dirname(host)) + for table <- ["driver_tab_android.zig", "driver_tab_ios.zig"] do + assert File.read!(Path.join(host, "priv/generated/#{table}")) =~ + "extern fn nx_eigen_nif_init()" end end - test "__enabled_builtin_nifs__/1 maps each enabled guard to its built-in NIF" do - assert NativeBuild.__enabled_builtin_nifs__(fn _ -> false end) == [] + # EMLX is iOS-only and mob's core iOS table already registers it. + test "creates no table for a host whose only built-in NIF is in the core table" do + host = regen_host([{:emlx, "emlx"}]) - assert NativeBuild.__enabled_builtin_nifs__(&(&1 == "MOB_STATIC_TFLITE_NIF")) == [ - :tflite_nif - ] - - assert NativeBuild.__enabled_builtin_nifs__(&(&1 == "MOB_STATIC_NX_EIGEN_NIF")) == [ - :nx_eigen - ] + refute File.exists?(Path.join(host, "priv/generated")) end # A host Mix project (path deps on stub packages) with regen_driver_tab!/0 - # run inside it. Returns the host dir; its parent is the scratch root. + # run inside it. Returns the host dir; the scratch root goes on test exit. defp regen_host(deps) do suffix = System.unique_integer([:positive]) root = Path.join(System.tmp_dir!(), "mob_driver_tab_host_#{suffix}") + on_exit(fn -> File.rm_rf!(root) end) host = Path.join(root, "host") File.mkdir_p!(host)