From 331d01e3e11c63928137562d980bc93fd7ec14db Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 15 Oct 2026 11:04:11 -0600 Subject: [PATCH 1/2] Render mob_plugins.exs with sorted map keys (MOB-383) RuntimeManifest.render/1 printed the manifest with inspect, whose map key order follows atom-creation order on OTP 26+. A VM that created the atoms in another order (deploy vs doctor/--check) rendered different bytes, so `mix mob.regen_plugin_manifest --check` reported drift right after a regen. Print every map with sorted keys (custom_options: [sort_maps: true]). The regression test writes a manifest with every map-bearing section from a fresh VM that creates every atom in reverse, then runs the check's byte comparison in the test VM. --- AGENTS.md | 6 ++ CHANGELOG.md | 15 ++++ lib/mob_dev/plugin/runtime_manifest.ex | 16 +++- test/mob_dev/plugin/runtime_manifest_test.exs | 80 +++++++++++++++++++ 4 files changed, 116 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index bfe86e6..493f999 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -525,6 +525,12 @@ Real fix: `sim_runtime_dir/0` should detect `ios/build.zig` and use shipping needs the user to wire the archive into ios/build.zig + android/jni/ themselves. Reach for `--type c` if static linking matters more than the source language. +- **Generated files that print terms with `inspect` must pass + `custom_options: [sort_maps: true]`.** On OTP 26+ a map prints its keys in + atom-creation order, which differs between VMs, so a committed file that a + `--check` re-renders and compares byte-for-byte reports drift right after a + regen (MOB-383: `RuntimeManifest.render/1`, `priv/generated/mob_plugins.exs`). + Text built by iterating a map needs an explicit sort for the same reason. - **`mob.regen_driver_tab` reads `:static_nifs` from `mob.exs`** via `Config.Reader`, NOT from `Application.get_env`. mob.exs is not auto-imported into Mix application env (this matches every other diff --git a/CHANGELOG.md b/CHANGELOG.md index a7f97ab..a82a73e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## [Unreleased] + +### Fixed + +- **`mix mob.regen_plugin_manifest --check` no longer reports drift right + after a regen (MOB-383).** `priv/generated/mob_plugins.exs` printed each + map's keys in atom-creation order, which on OTP 26+ differs between VMs, so + the file a deploy wrote and the one `--check` (and `mix mob.doctor`) + rendered could differ in key order alone. Every map in the file, nested + ones included, is now printed with sorted keys, so the same plugins always + produce the same bytes. **Upgrading:** the first regen after updating + rewrites a committed `mob_plugins.exs` with its keys reordered (no content + change); run `mix mob.regen_plugin_manifest` once (or let the next deploy do + it) and commit the result, or `--check` reports drift until you do. + ## [0.7.25] - 2026-10-13 ### Added diff --git a/lib/mob_dev/plugin/runtime_manifest.ex b/lib/mob_dev/plugin/runtime_manifest.ex index 93e5a6a..2beb5f2 100644 --- a/lib/mob_dev/plugin/runtime_manifest.ex +++ b/lib/mob_dev/plugin/runtime_manifest.ex @@ -164,15 +164,29 @@ defmodule MobDev.Plugin.RuntimeManifest do @doc """ Renders the manifest map to the contents of `priv/generated/mob_plugins.exs` — a self-describing `.exs` that evaluates back to the map. + + Every map, nested ones included, is printed with its keys sorted, so the same + manifest renders to the same bytes in any VM. Without that, a map's printed key + order follows atom-creation order (OTP 26+), which differs between runs, and + `mix mob.regen_plugin_manifest --check` reported drift right after a regen + (MOB-383). """ @spec render(map()) :: String.t() def render(manifest) do + terms = + inspect(manifest, + limit: :infinity, + printable_limit: :infinity, + pretty: true, + custom_options: [sort_maps: true] + ) + """ # Generated by `mix mob.regen_plugin_manifest` — do not edit by hand. # # The activated plugins' tier-3/4 contributions, read at boot by Mob.Plugins. # Regenerated whenever `config :mob, :plugins` changes (the deploy/regen hook). - #{inspect(manifest, limit: :infinity, printable_limit: :infinity, pretty: true)} + #{terms} """ end diff --git a/test/mob_dev/plugin/runtime_manifest_test.exs b/test/mob_dev/plugin/runtime_manifest_test.exs index 3ed9202..762645b 100644 --- a/test/mob_dev/plugin/runtime_manifest_test.exs +++ b/test/mob_dev/plugin/runtime_manifest_test.exs @@ -32,6 +32,54 @@ defmodule MobDev.Plugin.RuntimeManifestTest do defp base(extra), do: Map.merge(%{name: :p, mob_version: "~> 0.6", plugin_spec_version: 1}, extra) + # A manifest with every map-bearing section, each map keyed partly by `a` and + # `z` (atoms the caller creates). Keys are written in sorted order here; + # @reverse_order_writer builds the same manifest with every key reversed. + defp every_section_manifest(a, z) do + %{ + composites: [%{:atom => :card, :expand => {P.Card, :expand}, :plugin => :p, a => 1, z => 2}], + default_font: %{:android => "inter_regular", :ios => "Inter", a => 1, z => 2}, + default_style: :brand, + lifecycle: [%{:on_start => {P, :start, []}, :plugin => :p, a => 1, z => 2}], + nifs: [P.Nif], + notification_handlers: [ + %{:handler => {P, :h, 1}, :match => %{:type => "t", a => 1, z => 2}, :plugin => :p} + ], + plugins: [:p], + screens: [%{:default_route => "/p", :module => P.Home, :plugin => :p, a => 1, z => 2}], + settings: [ + %{:plugin => :p, :schema => [%{:default => 3, :key => :x, a => 1, z => 2}]} + ], + styles: [%{:name => :brand, :theme => %{:colors => %{a => 1, z => 2}, a => 1, z => 2}}] + } + end + + # Run in a fresh VM: creates `z` before `a` and writes every map literal in + # reverse key order, so each atom's creation order is the reverse of this + # VM's. Prints the written path. + @reverse_order_writer """ + [root, a_name, z_name] = System.argv() + z = String.to_atom(z_name) + a = String.to_atom(a_name) + + manifest = %{ + styles: [%{:theme => %{z => 2, a => 1, :colors => %{z => 2, a => 1}}, :name => :brand}], + settings: [%{:schema => [%{z => 2, a => 1, :key => :x, :default => 3}], :plugin => :p}], + screens: [%{z => 2, a => 1, :plugin => :p, :module => P.Home, :default_route => "/p"}], + plugins: [:p], + notification_handlers: [ + %{:plugin => :p, :match => %{z => 2, a => 1, :type => "t"}, :handler => {P, :h, 1}} + ], + nifs: [P.Nif], + lifecycle: [%{z => 2, a => 1, :plugin => :p, :on_start => {P, :start, []}}], + default_style: :brand, + default_font: %{z => 2, a => 1, :ios => "Inter", :android => "inter_regular"}, + composites: [%{z => 2, a => 1, :plugin => :p, :expand => {P.Card, :expand}, :atom => :card}] + } + + IO.puts(MobDev.Plugin.RuntimeManifest.write(root, manifest)) + """ + describe "build/1" do test "combines static and generated screens, each tagged with its plugin" do plugins = [ @@ -236,6 +284,38 @@ defmodule MobDev.Plugin.RuntimeManifestTest do {evaluated, _} = Code.eval_string(RuntimeManifest.render(manifest)) assert evaluated == manifest end + + # MOB-383: a map prints its keys in atom-creation order (OTP 26+), so a VM + # that created the atoms in another order rendered other bytes and + # `mix mob.regen_plugin_manifest --check` reported drift right after a regen. + # A fresh VM writes the manifest after creating every atom in reverse; this + # VM, which created them in forward order, runs the check's comparison. + test "a manifest written by a VM with another atom order passes the --check comparison" do + id = System.unique_integer([:positive]) + {a_name, z_name} = {"mob383_alpha_#{id}", "mob383_zeta_#{id}"} + a = String.to_atom(a_name) + z = String.to_atom(z_name) + manifest = every_section_manifest(a, z) + + root = Path.join(System.tmp_dir!(), "mob_rtm_order_#{id}") + on_exit(fn -> File.rm_rf!(root) end) + + ebin = RuntimeManifest |> :code.which() |> to_string() |> Path.dirname() + + {out, status} = + System.cmd( + System.find_executable("elixir"), + ["-pa", ebin, "-e", @reverse_order_writer, root, a_name, z_name], + stderr_to_stdout: true + ) + + assert status == 0, out + path = String.trim(out) + + {written, _} = Code.eval_file(path) + assert written == manifest + assert File.read!(path) == RuntimeManifest.render(manifest) + end end describe "write/1" do From d5b21737a20ec25f51fc62105eb177100818a111 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 15 Oct 2026 11:08:32 -0600 Subject: [PATCH 2/2] Test: locate the child VM's ebin via app_dir, flunk without elixir, don't parse stdout (MOB-383 review) --- test/mob_dev/plugin/runtime_manifest_test.exs | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/test/mob_dev/plugin/runtime_manifest_test.exs b/test/mob_dev/plugin/runtime_manifest_test.exs index 762645b..9e62c33 100644 --- a/test/mob_dev/plugin/runtime_manifest_test.exs +++ b/test/mob_dev/plugin/runtime_manifest_test.exs @@ -56,7 +56,7 @@ defmodule MobDev.Plugin.RuntimeManifestTest do # Run in a fresh VM: creates `z` before `a` and writes every map literal in # reverse key order, so each atom's creation order is the reverse of this - # VM's. Prints the written path. + # VM's. @reverse_order_writer """ [root, a_name, z_name] = System.argv() z = String.to_atom(z_name) @@ -77,7 +77,7 @@ defmodule MobDev.Plugin.RuntimeManifestTest do composites: [%{z => 2, a => 1, :plugin => :p, :expand => {P.Card, :expand}, :atom => :card}] } - IO.puts(MobDev.Plugin.RuntimeManifest.write(root, manifest)) + MobDev.Plugin.RuntimeManifest.write(root, manifest) """ describe "build/1" do @@ -300,17 +300,25 @@ defmodule MobDev.Plugin.RuntimeManifestTest do root = Path.join(System.tmp_dir!(), "mob_rtm_order_#{id}") on_exit(fn -> File.rm_rf!(root) end) - ebin = RuntimeManifest |> :code.which() |> to_string() |> Path.dirname() + elixir = System.find_executable("elixir") || flunk("elixir not on PATH") {out, status} = System.cmd( - System.find_executable("elixir"), - ["-pa", ebin, "-e", @reverse_order_writer, root, a_name, z_name], + elixir, + [ + "-pa", + Application.app_dir(:mob_dev, "ebin"), + "-e", + @reverse_order_writer, + root, + a_name, + z_name + ], stderr_to_stdout: true ) assert status == 0, out - path = String.trim(out) + path = Path.join([root, "priv", "generated", "mob_plugins.exs"]) {written, _} = Code.eval_file(path) assert written == manifest