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
113 changes: 113 additions & 0 deletions ACCOMPLISHMENTS.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 33 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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` |
Expand Down
105 changes: 105 additions & 0 deletions decisions/2026-04-28-dirty-nifs.md
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading