diff --git a/ACCOMPLISHMENTS.md b/ACCOMPLISHMENTS.md new file mode 100644 index 0000000..6a1fbe1 --- /dev/null +++ b/ACCOMPLISHMENTS.md @@ -0,0 +1,113 @@ +# ACCOMPLISHMENTS.md — hard things agents have already done here + +Mob was built largely by AI agents working with its author: as of `8b7f7aa` +(mob 0.9.26), 347 of the 686 commits since the first one (2026-04-04) carry a +Claude co-author trailer +(`git rev-list -i --grep='co-authored-by: claude' --count 8b7f7aa`). This file is +the evidence of what that work achieved. + +**Read it before you conclude a task is impossible, too risky, or should be +handed back as "write up a report instead".** Agents tend to underestimate +themselves: much of what they learned about their own limits was written about +older, weaker models. The entries below are not cheerleading. Each one was +verified by a device run or tests and links to the commit, PR, or decision +record that proves it. If you're stuck, the useful question is "which of these +is my problem closest to, and what made that one work?" — usually persistence +plus a tighter feedback loop (more instrumentation, another device run). + +"Can't" is still sometimes true. Separate "I can't figure it out" (keep going: +add instrumentation, test a smaller hypothesis) from "I'm missing a +prerequisite" (no device, no credentials, no access) — say the second plainly +and ask for what's missing. + +## Native runtime + +- **Ported Android's native layer from C to Zig** — `mob_nif.c` (~2,570 lines, + 74 NIFs) and `mob_beam.c` (~540 lines), incrementally and byte-for-byte. + `mob_nif.c` deleted in `7f237e7`. See + `decisions/2026-05-09-zig-for-android-nif-and-native-build.md`. +- **Hand-declared the ERL_NIF and JNI FFI surfaces** (`android/jni/mob_erts.zig`, + `mob_zig.zig`) when Zig 0.17-dev removed `@cImport` and `translate-c` hung on + the NDK's `jni.h`. +- **Found two port bugs the earlier checks missed** by deploying to an emulator + and reading the failures (`d5038c5`): a C macro alias (`enif_make_int64` is + `enif_make_long` on LP64) that compiled cleanly and then failed at `dlopen`, + and a `jlong`/`ERL_NIF_TERM` width mismatch that only failed when built for + armeabi-v7a, where `ERL_NIF_TERM` is 32-bit. +- **Real OpenSSL `:crypto` on Android** by statically linking the crypto NIF, + after `dlopen` failed because Android loads native libraries `RTLD_LOCAL` + (`09dcf3c`; AGENTS.md pre-empt rules 12–13). +- **Fixed DNS that failed only on physical Android devices** (`:nxdomain` from + the BEAM's resolver, fine on the emulator) with an in-process Bionic + `getaddrinfo` NIF (`72a22de`). +- **Got Zigler NIFs deploying to a real iPhone** by patching a Zigler fork + (SDK root option, panic-handler selection) until + `mix mob.add_nif --type zigler` worked end-to-end on device (`2a0ec60`). +- **Found an 800 ms whole-VM stall** — harness input NIFs blocking the single + normal scheduler for the length of a long-press — and moved them to dirty IO + schedulers (#135, `03f8eb3`; `decisions/2026-09-05-input-nifs-are-dirty-io.md`, + reversing that part of `decisions/2026-04-28-dirty-nifs.md`). +- **Diagnosed iOS motion data that was off by both a scale factor and a sign** — + CoreMotion reports G with gravity pointing down; Android reports m/s² specific + force — and made the platforms agree, guarded by a source-parsing test + (#70, `cac3928`). + +## Tools that let agents see, hear, and drive the app + +- **Screenshot, scroll control, and element frames over Erlang distribution** — + an agent gets pixels and exact on-screen positions from the app's own process, + no adb/xcrun (#40, `69ebada`; `decisions/2026-05-29-bridge-nif-screenshot-scroll.md`). +- **Caught the harness lying**: on a physical iPhone, injected taps were accepted + and never delivered, so `tap_xy` reported success for taps that did nothing. + It now reports the observed effect, and colour is checked by sampling pixels + (#80, `331f9b4`; `decisions/2026-08-09-ios-device-tap-injection-has-no-effect.md`, + `decisions/2026-08-09-tap-xy-reports-observed-effect.md`). +- **Audio output probes** — "is sound actually coming out right now": volume, + mute, and route, plus the signal level of `Mob.Audio`'s own player (#54, + `4a10d57`). Metering audio that bypasses `Mob.Audio` turned out not to be + possible in core on either platform; the probe says so instead of guessing. +- **Audio input metering** — the agent's "ears" (#67, `3a8660d`). +- **iOS screenshots in release builds**, opt-in with `-DMOB_ENABLE_SCREENSHOT` + (stripped by default), without shipping the private APIs that get an app + rejected from the App Store (#71, `cfb8def`). + +## Device features, verified on hardware + +- **Torch**, lighting the real flash on a moto g power (toggled over dist) and + an iPhone SE (from an on-device button) (#61, `f95700a`; + `decisions/2026-07-04-torch.md`). +- **Magnetometer / compass** in `Mob.Motion`, real readings on a moto g and an + iPhone SE, plus graceful degradation on a device without one (#59, `a2b8f69`). + +## Architecture + +- **Plugin system tiers 3 and 4** — plugins that ship whole screens and embedded + sub-apps (`7721bbf`, `b624a5b`; `decisions/2026-06-06-plugin-tiers-3-4.md`). +- **Extracted Bluetooth and Location from core into plugins**, native code + included (Bluetooth: `8af92b9` Elixir, `4f1849d` native; Location: `3c8c11d`; + `plugin_extraction_plan.md`). +- **Pure-Elixir composite components** via a new render expansion pass + (`2e5927b`; `decisions/2026-06-11-composite-expansion-pass.md`). +- **Moved off the single screen process** to one process per live screen, + owned and linked by `Mob.Screen` (MOB-108), and recorded the part that was + attempted, reviewed, and reverted + (`decisions/2026-08-28-screen-processes-and-supervision.md`, + `decisions/2026-08-29-migration-off-the-single-screen-process.md`). +- **Replaced the bash build pipeline with Zig builds and tested Elixir release + tooling** across mob, mob_dev, and mob_new (`build_system_migration.md`). + +## Measurement + +- **Battery cost of an on-device BEAM, measured** (`949fcd1`, + `guides/why_beam.md`): a 60-minute screen-off iPhone run with 360/360 probes + answered and no measurable drain; screen-off Android runs on a Moto E and a + Moto G ranged from 0 to ~143 mAh/hr, all below the ~200 mAh/hr no-BEAM + baseline. + +## Adding an entry + +Add one when you ship something that was hard, surprising, or that you (or a +previous agent) first thought couldn't be done. Keep it to what happened, what +made it hard, and the evidence: commit, PR, or decision record. Only verified +work goes here — an inflated entry teaches the next agent false confidence, +which is worse than none. diff --git a/AGENTS.md b/AGENTS.md index ec767a2..5e831ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -561,6 +561,19 @@ So: - **When you publish a number that turns out wrong, retract it in place** and say what was wrong. Someone will otherwise act on it. +## Before you say it can't be done + +Read [`ACCOMPLISHMENTS.md`](ACCOMPLISHMENTS.md). It lists hard problems agents +have already solved in this repo, each with its evidence. An agent's sense of +its own limits is mostly inherited from text about older models; that file is +current evidence. If you're stuck, add instrumentation and run another +experiment before proposing to give up or "write a report instead". If you're +genuinely blocked on a missing prerequisite (device, credentials, access), say +that plainly instead. + +When you ship something hard, surprising, or previously judged impossible, add +a verified entry to `ACCOMPLISHMENTS.md` in the same commit. + ## Pre-empt-failure rules — read before you touch anything These are the things we've burned ourselves on. Following them isn't optional. @@ -733,6 +746,14 @@ These are the things we've burned ourselves on. Following them isn't optional. user actions hooks there through `Mob.Event.NativeInput.kind/1`, and never records the payload: text-field values are user data (MOB-305). +20. **Core `android/jni/` is Zig; don't add C there.** Agents + default to C for NIFs because that's what the examples are written in. The + choice was deliberate: a NIF crash kills the whole VM, and Zig's types, + `defer` (release on the line after the acquire, run on every return), and + `comptime` earn their keep there. iOS stays Objective-C. Hand-declared FFI symbols + must match the C header *after* macro expansion — `d5038c5` is what happens + otherwise. See `decisions/2026-05-09-zig-for-android-nif-and-native-build.md`. + ## Device shapes Foldables (iPhone Duo), iPad and Split View: layout keys on `:size_class` (MOB-204), never orientation; the fold will be `:reserved_regions` (MOB-202, not yet shipped), kept apart from `:safe_area`. @@ -787,19 +808,19 @@ mix format # apply Elixir formatting mix credo --strict # **whole tree, not just changed files** — includes ExSlop (catches AI-generated patterns: blanket rescue, narrator docs, etc). Pre-existing issues are tracked separately, but new ones (including in tests) must be fixed mix erlfmt --check src/ # Erlang formatting (src/mob_nif.erl) xcrun clang-format --dry-run -Werror \ - ios/*.m ios/*.c \ - android/jni/*.c android/jni/*.h # C/ObjC formatting + ios/*.m \ + android/jni/*.h # C/ObjC formatting swiftlint ios/ # Swift linting (brew install swiftlint) ``` Auto-fix formatting (don't use for check-only CI): ```bash mix erlfmt --write src/ -xcrun clang-format -i ios/*.m ios/*.c android/jni/*.c android/jni/*.h +xcrun clang-format -i ios/*.m android/jni/*.h swiftlint --fix ios/ ``` -For native-code changes (iOS `.m`, Android `.kt` / `.c`), Elixir tests don't +For native-code changes (iOS `.m`, Android `.kt` / `.zig`), Elixir tests don't exercise the change. Deploy with `mix mob.deploy --native` and verify manually with a screenshot or `Mob.Test` interaction before committing. @@ -827,6 +848,12 @@ decision keeps the log conflict-free across parallel agents/worktrees — the date-sorted directory listing is the index. Record a decision the moment you make a non-obvious call, not later. +Record a decision **especially when it goes against what an agent would +propose by default** (Zig over C for NIFs). Without a +record, every new session starts from the default and re-argues it. If you +find such a call that was never recorded, write it up retroactively: date the +file when the decision was made and add a `Recorded:` line with today's date. + ### Decision log — check both directions Before committing, ask two questions, not one. @@ -1106,7 +1133,8 @@ semantics. | Open known issues with diagnoses + fixes | `issues.md` | | Speculative ideas, longer-term plans | `future_developments.md`, `wire_tap.md`, `PLAN.md` | | Per-feature deep dives (events, navigation, theming, ...) | `guides/*.md` | -| Architecture decisions (one ADR per cross-cutting decision) | `decisions/` | +| Architecture decisions (one ADR per cross-cutting decision) | `decisions/` → "Decision log" under Commit workflow | +| "Is this even possible?" — hard things already done here | `ACCOMPLISHMENTS.md` | | iOS device deployment (provisioning, build chain, gotchas) | `guides/ios_physical_device.md` | | Generator templates (mob_new) | `mob_new/priv/templates/mob.new/` | | Build / release tooling | `mob_dev/scripts/release/`, `mob_dev/build_release.md` | diff --git a/decisions/2026-04-28-dirty-nifs.md b/decisions/2026-04-28-dirty-nifs.md new file mode 100644 index 0000000..572dfc7 --- /dev/null +++ b/decisions/2026-04-28-dirty-nifs.md @@ -0,0 +1,105 @@ +# Dirty NIF scheduling for `mob_nif` + +- Date: 2026-04-28 +- Status: accepted for the four CPU-bound NIFs; the "everything else stays on + regular schedulers" list is superseded by `2026-09-05-input-nifs-are-dirty-io.md` + and its 2026-09-06 correction +- Scope: `ios/mob_nif.m`, `android/jni/mob_nif.zig` (was `mob_nif.c` when written) + +> Restored 2026-10-24. Originally `docs/decisions/0001-dirty-nifs.md`, removed +> in the unrelated commit `88532d9` while `ios/mob_nif.m` still cited it. The +> text below is the original, except the Android "See also" pointer, which now +> names `mob_nif.zig`'s `nif_funcs` table. Since then the input NIFs and many property +> queries this record kept on regular schedulers have moved to dirty IO +> (`2026-09-05-input-nifs-are-dirty-io.md`). The `nif_funcs` tables are the +> current source of truth. "How to extend" step 3 predates the append-only +> rule: record new dirty NIFs in a new decision, not in the table below. + +## Context + +Until 2026-04-28, every entry in the iOS and Android `nif_funcs[]` tables used +flag `0` — i.e. all ~60 NIFs ran on regular schedulers. Two patterns were +mixed in that single bucket: + +1. **Dispatch-and-return** (the majority). A NIF wraps `dispatch_async` to the + main UI queue and returns in microseconds. Trivially safe on a regular + scheduler. + +2. **Synchronous BEAM-thread CPU work**. Four NIFs do real CPU on the BEAM + thread before any dispatch, or `dispatch_sync` and walk a recursive object + graph while holding the main queue. With flag `0`, the regular scheduler + thread is blocked for as long as the NIF runs — which can be 10s of ms + for a complex screen. + +The `1ms` rule for regular NIFs (Erlang efficiency guide) was being violated +intermittently for the second category. Symptoms would be sporadic latency +spikes on whichever Erlang process happens to share the scheduler with a +busy `set_root` call. + +## Decision + +Mark the four CPU-heavy NIFs as `ERL_NIF_DIRTY_JOB_CPU_BOUND`: + +| NIF | Why dirty CPU | +|---|---| +| `set_root/1` | `NSJSONSerialization` parse + recursive `MobNode` tree construction on the BEAM thread, *before* any dispatch. Scales with render-tree size. Called every render. | +| `set_transition/1` | Same call pattern as `set_root`; sibling. | +| `ui_tree/0` | `dispatch_sync` to main queue + recursive `walk_a11y` over the entire `UIApplication` window graph. Variable cost; can be many ms on a screen with hundreds of accessibility elements. | +| `ui_debug/0` | Same walk as `ui_tree`, more output. | + +Everything else stays on regular schedulers, including: + +- **All synthetic-input NIFs** (`tap`, `tap_xy`, `swipe_xy`, `long_press_xy`, + `type_text`, `key_press`, `delete_backward`, `clear_text`). They `dispatch_sync` + to the main queue but do little BEAM-thread work. The test harness fires + these in tight loops; dirty-dispatch overhead (~3–10 µs/call) would + accumulate. Re-evaluate if benchmarks ever show the regular scheduler + stalling under heavy harness use. +- **Property queries** (`battery_level`, `safe_area`, `device_*`, `clipboard_get`, + `platform`, `webview_can_go_back`). Read a single property and return. +- **Fire-and-forget UI** (`haptic`, `share_text`, `alert_show`, `toast_show`, + `audio_play`, `camera_capture_*`, etc.). `dispatch_async` + return. +- **Bookkeeping** (`register_tap`, `clear_taps`, `register_component`, + `deregister_component`, `log`, `set_dispatcher`). Mutex + struct fields. + +## Risks and mitigations + +1. **Dispatch overhead** (~3–10 µs/call). Negligible vs the multi-millisecond + work the marked NIFs do. Not measurable for the unmarked ones. + +2. **Dirty CPU scheduler count under Nerves tuning**. We default to `-S 1:1`, + so OTP runs **one** dirty CPU scheduler. Concurrent `set_root` calls (e.g. + from two screens re-rendering) serialize. Mitigation: bump + `+SDcpu 2` (or whatever) via `mix mob.deploy --beam-flags "..."` if it + shows up in profiles. Has not been observed to date. + +3. **Thread locality**. Dirty NIFs run on different OS threads than regular + NIFs. mob's NIFs use proper mutexes (`tap_mutex`, `component_mutex`) and + `dispatch_sync` to the OS main queue, both scheduler-agnostic. No code + secretly assumes "next NIF runs on the same thread as me". + +4. **Reverting**. Trivial — change the flag back to `0` in the relevant + `nif_funcs[]` row. No state migration, no rebuild of unrelated code. + +## How to extend the decision + +If you're tempted to add `ERL_NIF_DIRTY_JOB_*` to a new NIF, use this checklist: + +1. **Profile first.** Use the Erlang VM's scheduler-stall reports (`+swt very_high`) + or wallclock-time logging to confirm the NIF actually does enough work to + matter. Many NIFs that *look* heavy are actually `dispatch_async` + return + in single-digit microseconds. +2. **Pick the right kind.** `CPU_BOUND` for real computation on the BEAM + thread. `IO_BOUND` for waiting on another thread (e.g. blocking + `dispatch_sync` to a busy main queue). +3. **Keep this list updated.** If you add a new dirty NIF, append it to the + table above and to the inline comment block in the corresponding + `nif_funcs[]`. +4. **Watch the dispatch overhead.** Dirty scheduling adds a few µs per call. + For NIFs called at video frame rate, it can be net-negative. + +## See also + +- `ios/mob_nif.m` — search for "Scheduling notes" above the iOS `nif_funcs[]` +- `android/jni/mob_nif.zig` — the `nif_funcs` table at the end of the file +- Erlang efficiency guide — [Dirty NIFs](https://www.erlang.org/doc/system/nif.html#dirty-nifs) diff --git a/decisions/2026-05-09-zig-for-android-nif-and-native-build.md b/decisions/2026-05-09-zig-for-android-nif-and-native-build.md new file mode 100644 index 0000000..810a896 --- /dev/null +++ b/decisions/2026-05-09-zig-for-android-nif-and-native-build.md @@ -0,0 +1,97 @@ +# Zig for the Android NIF bridge and the native build + +- Date: 2026-05-09 (greenlit with the build-system migration; Android port shipped 2026-05-11) +- Status: accepted +- Recorded: 2026-10-24, retroactively. The rationale was spread across + `build_system_migration.md` and conversation; this file makes it findable. + +## Context + +- Android's native layer was C: `android/jni/mob_nif.c` (~2,570 lines, 74 NIFs) + and `mob_beam.c` (~540 lines). Native builds were bash `build.sh` templates + calling `xcrun cc` / NDK clang, and the static-NIF tables were hand-edited + `driver_tab_{ios,android}.c` files that were easy to let drift between platforms + (`build_system_migration.md` → "Why"). +- A NIF runs inside the VM. A segfault or memory corruption there is not a + process crash a supervisor restarts — it kills the BEAM, which in Mob is the + whole app. The NIF bridge is the one layer Mob's fault tolerance doesn't + cover, so it deserves the most careful language we can get. +- The conventional answer is C: `erl_nif.h`, the OTP docs, the NDK samples and + nearly every published NIF are C. AI agents propose C by default for that + reason. + +## Decision + +- **Android NIF bridge and BEAM launcher are Zig.** `mob_nif.zig` and + `mob_beam.zig`, over hand-declared FFI in `mob_erts.zig` (ERL_NIF surface) + and `mob_zig.zig` (JNI, libc, liblog, dlfcn, pthreads). Ported incrementally + and byte-for-byte; `mob_nif.c` deleted in `7f237e7`. +- **Static-NIF driver tables are Zig** (`driver_tab_android.zig`, + `driver_tab_ios.zig`, introduced in `b361383`), replacing the hand-edited C + tables (the `.c` files were deleted in `ab9ff59`). The + iOS table is comptime-gated through `build_options` instead of `#ifdef`. +- **`build.zig` orchestrates the native build.** The mob_new templates replace + the bash scripts: Zig and plain C (OTP glue, sqlite, plugin C NIFs) compile + through Zig for every target. Apple clang / `swiftc` still build ObjC and + Swift (Zig's bundled clang can't build Apple framework module maps), and NDK + clang is still the Android link driver. +- **iOS stays Objective-C** (`ios/mob_nif.m`). UIKit/Cocoa APIs are ObjC and + ARC manages memory; Zig there would mean wrapping the platform's own idiom. +- **Hand-declare the FFI; no `@cImport`.** During the port, Zig 0.17-dev had + removed `@cImport` and `translate-c` hung on the NDK's `jni.h`. Both surfaces + are small and stable (the JNI ABI is essentially unchanged since Java 1.1). + libc declarations go through `mob_zig.zig` too + (`decisions/2026-09-14-android-libc-decls-through-mob_zig.md`). + +Why Zig over C, concretely: + +- Types carry what C leaves implicit: optional pointers instead of implicit + null, slices that know their length, error unions instead of ignorable return + codes, `comptime` constraints (e.g. `erts.atom` requires a compile-time-known + name). +- `defer` / `errdefer` put the release on the line right after the acquire, + and it runs on every exit path. C NIF code releases at each `return`, and an + early return added later is exactly where a leak or a held lock slips in — + for humans, and even more for agents editing one branch of a long function. + With `defer`, reviewing a resource means reading two adjacent lines, not + tracing every return. Before the port, `mob_nif.c` released resources by hand + (72 explicit `enif_free_env` / `enif_mutex_unlock` / `ReleaseStringUTFChars` + calls). +- The C ABI is native — `callconv(.c)` exports and `extern fn` imports + (`@extern` where the symbol name is chosen at comptime), no glue layer + between Zig and libbeam/JNI. +- `comptime` replaces both the preprocessor and the code-generation step. + +## Consequences + +- **`defer` adoption is partial.** In `mob_nif.zig`, 40 of 45 `enif_free_env` + calls are deferred, but only 7 of 22 `enif_mutex_unlock` and 2 of 6 + `releaseStringUTFChars` calls are; the rest still release by hand on each + return, C-style. New code pairs every acquire with a `defer`; converting the + remaining manual sites is open work. +- **Runtime safety checks are off in every build.** The mob_new templates + hardcode `.ReleaseSmall`, which disables bounds and overflow checks, for dev + deploys as well as releases. Zig's contribution here is the type-level + discipline above, not runtime panics. Hunting a memory bug with checks on + means editing the app's `build.zig` to `.ReleaseSafe`. +- **Zig is pre-1.0, and more than one pin is live.** The repo pins Zig in + `.tool-versions`, but apps on an older mob_dev build with an older Zig, so the + Android sources must compile on both (`ea6f06b`, #226). Treat a Zig bump like + an OTP bump, and use only std APIs both pins have. +- **C-preprocessor aliases don't exist in Zig, and one target isn't all + targets.** The port's first deploy, to an emulator, caught two bugs + (`d5038c5`): `enif_make_int64` is a macro for `enif_make_long` on LP64, so + the declaration compiled and then failed at `dlopen`; and a `@bitCast` + between `jlong` (always 64-bit) and `ERL_NIF_TERM` (32-bit on armeabi-v7a) + failed only when built for that ABI, which the earlier aarch64-only check + never did. Check every hand-declared symbol against the header's `#define`s, + build every ABI, and smoke-deploy after any port. +- **Core's `android/jni/` is Zig-only; don't add C there.** Plugin NIFs are a + separate path: on Android they may be C (the manifest default) or Zig + (`lang: :zig`, mob_dev `decisions/2026-05-28-zig-plugin-nifs.md`); on iOS + they're ObjC/C (mob_dev `decisions/2026-06-04-ios-plugin-c-nifs.md`). +- **Agents will propose going back to C.** Their training data defaults to C, + and their Zig knowledge is thinner and often from older Zig versions. Don't + relitigate without evidence beyond "C is more common". When unsure of Zig + syntax for the pinned version, read the existing code in `android/jni/` or + the Zig standard library source for that version rather than guessing. diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 2109f92..fbf0a3e 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -9247,7 +9247,7 @@ static ERL_NIF_TERM nif_post_mortem_android_drain(ErlNifEnv *env, int argc, return enif_make_list(env, 0); } -// Scheduling notes for nif_funcs[] below — see docs/decisions/0001-dirty-nifs.md +// Scheduling notes for nif_funcs[] below — see decisions/2026-04-28-dirty-nifs.md // for the full rationale. Short version: most NIFs here either dispatch_async // to the main queue and return in microseconds, or dispatch_sync but read a // single property. Those stay on a regular scheduler.