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
10 changes: 8 additions & 2 deletions .claude/skills/forge-error-catalogue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ description: >-
schema errors, meson source-tree include / sanity-check breakage,
duplicated patch hunks, iOS flatc MACOSX_BUNDLE, .dylib-vs-.so staging,
libc++_shared, config.sub apple-ios, ctypes "Unable to find ... shared
library", PT_LOAD 16KB alignment, lazy_loader "non-existent stub" crashes
library", a green iOS build that silently ships another project's site-packages
(headerpad), PT_LOAD 16KB alignment, lazy_loader "non-existent stub" crashes
(stripped *.pyi), the Flet 0.86 Android sitepackages.zip class (NotADirectoryError
on a bundled data file -> extract_packages, untagged native .so
ModuleNotFoundError -> forge ABI-tag, ctypes-by-__file__ loaders -> find_spec /
Expand Down Expand Up @@ -120,7 +121,12 @@ instead of re-deriving it.
of interdependent bundled dylibs (pyarrow, llama)** → **serious_python #223**:
reconcile framework install-ids + `@rpath` deps to the dotted-framework paths
(`reconcile_framework_install_names` in darwin scripts; needs an sp release for CI;
local sp fix cc28d13 verified pyarrow 4/4 on-sim).
local sp fix cc28d13 verified pyarrow 4/4 on-sim). **And its follow-on:** that same
reconcile pass FAILS on an extension linked without header padding
(`larger updated load commands do not fit`), which aborts the sync and leaves
`flet build` reporting success while shipping the shared pub-cache `dist_ios`'s
packages from some *other* project → **`-Wl,-headerpad_max_install_names`** on the
iOS lanes of any non-CMake recipe binding several bundled dylibs (av).
- **Recipe-tester app failures** — host-build (`pg_config` etc.), pypi.flet.dev
index precedence, "no matching distribution" (incl. **ios-simulator also
resolving the iphoneos wheel**), **sdist-only pure-python dep → pip backtrack**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,46 @@ flet build ios-simulator --python-version 3.12`) not `uvx` (else

---

### iOS: `flet build` reports SUCCESS but the `.app` carries **another project's** site-packages (or none) — `reconcile_framework_install_names … larger updated load commands do not fit`

**Symptom:** the build prints "Successfully built your .app bundle" and exits 0, but
on device the app dies with `ModuleNotFoundError` for the recipe under test. Unpack
the bundle and its Python tree is from a *different* build entirely:

<app>.app/serious_python_darwin_serious_python_darwin.bundle/site-packages/
-> bitarray/, msgpack/ … # a previous recipe's packages
-> no <your package>/, no _pytest/

`<app>.app/Frameworks/` has the stdlib frameworks but none for your extension
modules. Found while validating `av` (49 extension modules × 7 FFmpeg dylibs).

**Cause:** `install_name_tool` cannot grow a Mach-O load command in place. serious_python
rewrites every `@rpath/libavformat.dylib` to `@rpath/opt.lib.libavformat.framework/opt.lib.libavformat`
— **30 bytes longer** — and setuptools links extensions with no header padding, so it
fails with `changing install names or rpaths can't be redone … because larger updated
load commands do not fit`. `reconcile_framework_install_names` correctly treats that as
fatal (`|| exit 1` in `sync_site_packages.sh`), which aborts the sync **before** it
writes `dist_ios/site-packages`. That directory lives in the plugin's **shared pub
cache** (`~/.pub-cache/hosted/pub.dev/serious_python_darwin-<v>/darwin/dist_ios`), so
SwiftPM then packages whatever the *last successful* build of any project left there.
`flet build` does not propagate the failure, so nothing is red until the app runs.

**Fix (per-recipe):** link with `-Wl,-headerpad_max_install_names` on the iOS lanes.
For a PEP 517 / setuptools recipe that is `build.script_env.LDFLAGS` (forge appends to
LDFLAGS, and distutils' `customize_compiler` appends it to `LDSHARED`); for a `build.sh`
native lib it goes in the link flags the upstream build system takes (`--extra-ldflags`
for FFmpeg's configure). CMake recipes add it themselves on Apple platforms, which is
why pyarrow never hit this. Rule of thumb: **a non-CMake iOS recipe whose extensions
link more than a couple of bundled dylibs needs the flag.**

**Confirming it is this and not something else:** grep the `-vv` build log for
`reconcile_framework_install_names` — the failure is printed there in full, tens of
thousands of lines above the cheerful success banner. And after any iOS build, check
`ls <app>.app/serious_python_darwin_serious_python_darwin.bundle/site-packages` really
contains your package before trusting a green build.

---

### iOS: app builds + launches but **SIGSEGV at launch** in `dyld … runInitializers`, 0-byte `console.log` — an extension runs the Python C-API in a C++ **static initializer**

**Symptom:** same 0-byte `console.log` / crash-before-Python signature as the #223
Expand Down
18 changes: 13 additions & 5 deletions .claude/skills/local-recipe-testing/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: local-recipe-testing
description: Run a mobile-forge recipe's wheel ON-DEVICE locally — Android emulator and/or iOS simulator — instead of waiting ~1 hour for a CI mobile-test cycle. Covers the recipe-tester app loop (build wheel → stage → flet build → install → read console.log), and the non-obvious gotchas that each cost a wasted cycle: use forge's stripped dist/ wheel, build the recipe against the SAME Python flet bundles, clear flet's build cache between rebuilds, use a rootable (google_apis, not playstore) arm64 AVD to read the app-private console.log (it's in the app's cache/ dir), give the emulator enough RAM/disk, build ALL THREE iOS slices before `flet build ios-simulator`, use explicit simulator UDIDs when more than one sim is booted, and verify the staged-test COUNT so a silently-failed staging can't replay stale tests as false passes. Also covers forge slice syntax, bundling model assets next to recipe tests, test-only deps via the meta.yaml test.requires field, desktop pre-validation via a sys.modules alias shim, and consumer verify-apps for beyond-pytest validation. USE THIS SKILL when iterating on a recipe's on-device behaviour (import works? functions run? crashes?), reproducing or debugging a CI mobile-test failure locally, or whenever someone says the CI mobile test is too slow to iterate on. Sibling of `new-mobile-recipe` (authoring), `forge-ci` (CI runs), `forge-error-catalogue` (build errors), and `native-recipe-bumps` (version bumps); this one is specifically the fast on-device validation loop. macOS + Apple Silicon assumed (the host this was developed on).
description: Run a mobile-forge recipe's wheel ON-DEVICE locally — Android emulator and/or iOS simulator — instead of waiting ~1 hour for a CI mobile-test cycle. Covers the recipe-tester app loop (build wheel → stage → flet build → install → read console.log), and the non-obvious gotchas that each cost a wasted cycle: use forge's stripped dist/ wheel, build the recipe against the SAME Python flet bundles, clear flet's build cache between rebuilds, use a rootable (google_apis, not playstore) arm64 AVD to read the app-private console.log (it's in the app's cache/ dir), give the emulator enough RAM/disk, build ALL THREE iOS slices before `flet build ios-simulator`, use explicit simulator UDIDs when more than one sim is booted, verify the staged-test COUNT so a silently-failed staging can't replay stale tests as false passes, and check the built iOS `.app` actually carries your package (a failed site-packages sync still exits 0). Also covers forge slice syntax, bundling model assets next to recipe tests, test-only deps via the meta.yaml test.requires field, desktop pre-validation via a sys.modules alias shim, and consumer verify-apps for beyond-pytest validation. USE THIS SKILL when iterating on a recipe's on-device behaviour (import works? functions run? crashes?), reproducing or debugging a CI mobile-test failure locally, or whenever someone says the CI mobile test is too slow to iterate on. Sibling of `new-mobile-recipe` (authoring), `forge-ci` (CI runs), `forge-error-catalogue` (build errors), and `native-recipe-bumps` (version bumps); this one is specifically the fast on-device validation loop. macOS + Apple Silicon assumed (the host this was developed on).
---

# Testing a mobile-forge recipe locally
Expand Down Expand Up @@ -37,8 +37,6 @@ cp dist/<recipe>-*-android_24_arm64_v8a.whl /tmp/rt_dist/ # forge's dist/ whee
./tests/recipe-tester/stage_recipe.sh <recipe> <version>

# 3. Clear flet's stale bundle (gotcha #3), then build the app.
# The recipe-tester targets Flet 0.86 (only there since flet#104), which is NOT
# on PyPI yet — pull it from pypi.flet.dev and pin the prerelease (gotcha #13).
rm -rf tests/recipe-tester/build/site-packages tests/recipe-tester/build/.hash
cd tests/recipe-tester
PIP_FIND_LINKS=/tmp/rt_dist \
Expand Down Expand Up @@ -76,7 +74,7 @@ rm -rf tests/recipe-tester/build/site-packages tests/recipe-tester/build/.hash
cd tests/recipe-tester
PIP_FIND_LINKS="$(realpath ../../dist)" \
uvx --prerelease allow --with 'flet-cli' --with 'flet' \
flet build ios-simulator --yes --python-version 3.12 # 0.86 pin — gotcha #13
flet build ios-simulator --yes --python-version 3.12 # 0.86+ — gotcha #13

# 3. Boot any available iPhone sim, install, launch — ALWAYS by explicit UDID
# (gotcha #11: `booted` is ambiguous the moment two sims are booted)
Expand All @@ -87,7 +85,9 @@ xcrun simctl install "$UDID" build/ios-simulator/recipe-tester.app
xcrun simctl launch "$UDID" com.flet.recipe-tester
# NB bundle id: iOS uses a DASH (com.flet.recipe-tester); android package an UNDERSCORE (com.flet.recipe_tester)

# 4. Poll for the sentinel — the container is host-readable, no fixed sleep needed
# 4. Confirm the bundle really carries your package (gotcha #14), then poll for the
# sentinel — the container is host-readable, no fixed sleep needed
ls build/ios-simulator/recipe-tester.app/serious_python_darwin_serious_python_darwin.bundle/site-packages
DATA=$(xcrun simctl get_app_container "$UDID" com.flet.recipe-tester data)
for i in $(seq 1 30); do grep EXIT "$DATA/Library/Caches/console.log" 2>/dev/null && break; sleep 5; done
```
Expand Down Expand Up @@ -149,6 +149,14 @@ for i in $(seq 1 30); do grep EXIT "$DATA/Library/Caches/console.log" 2>/dev/nul
rule: **a search that returns a surprising negative is a bug until proven otherwise** — spot-check
one case you are certain about before reporting the absence as evidence.

13. **`--python-version` only exists in flet-cli 0.86+, and `uvx` can hand you 0.85.**

14. **After an iOS build, check the `.app`'s bundled site-packages actually contains your package.** `flet build ios-simulator` reports success and exits 0 even when serious_python's site-packages sync **aborted**, because the failure is not propagated. The plugin's `dist_ios` lives in the shared pub cache, so the SwiftPM resource bundle then ships whatever the last *successful* build of any project left there — an app carrying a different recipe's packages entirely, which on device is an ordinary-looking `ModuleNotFoundError`. One line, worth it every time:
```bash
ls build/ios-simulator/<app>.app/serious_python_darwin_serious_python_darwin.bundle/site-packages
```
The known cause is an extension linked without `-Wl,-headerpad_max_install_names` (see the `forge-error-catalogue` skill), but the check is cheap and catches the whole class. The Android twin is gotcha #12's `unzip -l build/apk/…`.

## Model assets & test-only deps

`stage_recipe.sh` copies **every** file in `recipes/<pkg>/tests/` into the app (`cp -r tests/. recipe_tests/`), so a model dropped next to the test file becomes an app asset. Two tiers:
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/new-mobile-recipe/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Match the package to one of these shapes. Each maps to a template in `templates/
| C-ext that links a lib via a `*-config` tool | Compiled C-ext whose `setup.py` shells out to `pg_config`/`mysql_config`/… (psycopg2→libpq, mysqlclient→libmysqlclient) | A **static+PIC** `flet-lib*` (`build-flet-lib.sh` + `-fPIC`) shipping a config-shim, + consumer `script_env`/patch; see Pattern I |
| **setup.py that drives CMake itself** for a vendored native lib | An sdist that vendors a C library and builds it with its own `subprocess` CMake call inside `build_ext`, then links the static result via `extra_objects` (pycares→c-ares). Not scikit-build-core — the arg list is hardcoded in `setup.py` | No template — copy `recipes/pycares/`: one patch appends `shlex.split(os.environ['FORGE_CMAKE_ARGS'])` to the arg list, `requirements.build: [cmake]`; see "vendored-CMake" deep-dive below |
| CMake giant, **no sdist AND no setup.py/pyproject.toml** | Upstream's only wheel path is a host==target build script (onnxruntime's `ci_build/build.py`, TF's `build_pip_package_with_cmake.sh`) | No template — copy from `recipes/onnxruntime/` or `recipes/tflite-runtime/` (branches `machine/onnxruntime` / `machine/tflite-runtime`); see "PEP 517 shim" deep-dive below |
| C-ext linking a `flet-lib*`'s **shared** libs via `pkg-config` | Upstream's `setup.py` runs `pkg-config --cflags --libs …` and the package has too many extension modules to static-link into (PyAV: 49) | No template — copy `recipes/flet-libffmpeg/` + `recipes/av/`: the flet-lib ships SHARED libs plus **relocatable** `.pc` files (forge already has `opt/lib/pkgconfig` on `PKG_CONFIG_LIBDIR`, so the consumer needs no `script_env` for it), and the consumer adds `-Wl,-headerpad_max_install_names` on iOS. See "Cross-cutting conventions" in `references/recipe-patterns.md` |
| **Prebuilt-repackage + host_build chain** | Upstream publishes official prebuilt mobile archives of the native lib AND the consumer's own cmake links + re-ships the `.so` (flet-libonnxruntime→sherpa-onnx) | `build.sh` repackager + consumer `requirements.host_build`; copy from `recipes/flet-libonnxruntime/` + `recipes/sherpa-onnx/` (branch `machine/sherpa-onnx`); see "prebuilt-repackage" deep-dive below |
| **cffi/ctypes consumer of a prebuilt archive** | The package's own build DOWNLOADS a prebuilt static lib keyed by the build host's `uname` and statically links it (curl-cffi → curl-impersonate); breaks under cross because host≠target | `flet-lib*` prebuilt-repackage dep (`source.strip: 0` for root-level tarballs) + `requirements.host_build` + a `mobile.patch` opt-in env lever that steers arch/link off the forge target; copy from `recipes/flet-libcurl-impersonate/` + `recipes/curl-cffi/` (branch `curl-cffi`); see deep-dive below |

Expand Down
45 changes: 45 additions & 0 deletions .claude/skills/new-mobile-recipe/references/recipe-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,51 @@ and `scipy` are the fixed precedents.
Getting it wrong the safe way costs a download; getting it wrong the unsafe way — moving a lib
something actually `dlopen`s — is a runtime crash on device, so verify per consumer.

### A `flet-lib*` consumed through `pkg-config` needs relocatable `.pc` files

forge already puts `<cross site-packages>/opt/lib/pkgconfig` on both `PKG_CONFIG_PATH`
and `PKG_CONFIG_LIBDIR` (`compile_env()` in `build.py`), so a consumer whose `setup.py`
shells out to `pkg-config` needs **no `script_env` at all** — provided the `flet-lib*`
ships its `.pc` files and they are relocatable. Most build systems bake absolute paths
from the staging prefix into all three of `prefix=`, `libdir=` and `includedir=`
(FFmpeg writes the latter two out in full rather than deriving them), and that prefix
no longer exists when the wheel is unpacked. Rewrite them in `build.sh`:

```bash
for pc in "$PREFIX"/lib/pkgconfig/*.pc; do
sed -E -e 's|^prefix=.*|prefix=${pcfiledir}/../..|' \
-e 's|^libdir=.*|libdir=${prefix}/lib|' \
-e 's|^includedir=.*|includedir=${prefix}/include|' \
"$pc" > "$pc.tmp" && mv "$pc.tmp" "$pc"
done
```

Note this contradicts the usual `flet-lib*` cleanup advice to delete `lib/pkgconfig` —
delete it only when nothing downstream reads it. **Real example:**
`recipes/flet-libffmpeg/` → `recipes/av/`.

### iOS: extensions binding several bundled dylibs need `-Wl,-headerpad_max_install_names`

serious_python relocates every bundled dylib into a framework and rewrites each
`@rpath/lib<X>.dylib` reference to the much longer
`@rpath/opt.lib.lib<X>.framework/opt.lib.lib<X>`. `install_name_tool` cannot grow a
load command that has no padding, and the resulting failure aborts the sync **while
`flet build` still reports success** — see the `forge-error-catalogue` skill for the
full symptom. CMake adds the flag itself on Apple platforms; setuptools and plain
`configure` do not, so pass it explicitly on the iOS lanes:

```yaml
build:
# {% if sdk != 'android' %}
script_env:
LDFLAGS: -Wl,-headerpad_max_install_names
# {% endif %}
```

and, for the `flet-lib*` underneath, in whatever the upstream build calls its link
flags (`--extra-ldflags` for FFmpeg). **Real example:** `recipes/av/` +
`recipes/flet-libffmpeg/`.

### Version specifiers in requirements

Forge parses `requirements.host` / `requirements.build` strings per `build.py:67-78`:
Expand Down
Loading
Loading