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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 15 additions & 1 deletion lib/mob_dev/plugin/runtime_manifest.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
88 changes: 88 additions & 0 deletions test/mob_dev/plugin/runtime_manifest_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
@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}]
}

MobDev.Plugin.RuntimeManifest.write(root, manifest)
"""

describe "build/1" do
test "combines static and generated screens, each tagged with its plugin" do
plugins = [
Expand Down Expand Up @@ -236,6 +284,46 @@ 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)

elixir = System.find_executable("elixir") || flunk("elixir not on PATH")

{out, status} =
System.cmd(
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 = Path.join([root, "priv", "generated", "mob_plugins.exs"])

{written, _} = Code.eval_file(path)
assert written == manifest
assert File.read!(path) == RuntimeManifest.render(manifest)
end
end

describe "write/1" do
Expand Down
Loading