Skip to content
Open
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
3 changes: 2 additions & 1 deletion .claude/skills/forge-ci/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ build-wheels.yml (push / workflow_dispatch / workflow_call)
└── jobs (matrix: package × platform) e.g. "Python 3.12 / foo 1.2.3 #1 (android)"
├── Build wheels — prebuild_recipes first, then the package(s), all slices
├── mobile test (3.12 legs only by default) — recipe-tester APK on an
│ emulator (android) / .app on a simulator (ios), polls console.log
│ x86_64 emulator (android; never arm64, see local-recipe-testing #15) /
│ .app on a simulator (ios), polls console.log
│ for the ">>>>>>>>>> EXIT N <<<<<<<<<<" sentinel
└── upload artifacts — wheels + test console.log
```
Expand Down
11 changes: 11 additions & 0 deletions .claude/skills/forge-error-catalogue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ instead of re-deriving it.
green host-configured library, fix by patching in a `FORGE_CMAKE_ARGS` extend.
**`conflicting types for 'fseek'` from a project's own `compat.h`, 32-bit Android only →
the NDK toolchain pins `CMAKE_SYSTEM_VERSION` to 1; read `ANDROID_PLATFORM_LEVEL`**.
**bindgen `stdlib.h` not found / `'sim'` triple invalid → forge's
`BINDGEN_EXTRA_CLANG_ARGS_<target>`**, **aws-lc-sys 0.40 external-bindgen → its `bindgen`
feature**, **iOS `library 'python3.10' not found` → drop pyo3 `abi3`**, **a crates.io dep
with no iOS/Android branch → `crate_patches`**, **a build script's bare `cmake` →
`CMAKE_TOOLCHAIN_FILE` via cargo `[env]`**.
- **Runtime failures** (device/emulator/simulator) — **the Flet 0.86 Android
`sitepackages.zip` class** (its umbrella entry explains "why only now"):
`NotADirectoryError` on a bundled data file → **`extract_packages`** meta field,
Expand Down Expand Up @@ -129,6 +134,12 @@ instead of re-deriving it.
`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).
Rust on Android: **`cannot locate symbol "__clear_cache"` (arm64 only) → forge links the
NDK's compiler-rt builtins**; **`fatal runtime error: out of TLS keys` → hidden pthread-key
multiplexing shim** (std spends a key per `thread_local!`; bionic has ~128);
**deno_core `Failed to initialize a JsRuntime: No such file or directory` → embed the
extension sources** (they are build paths without a snapshot); **`Expect
rustls-platform-verifier to be initialized` → `tls_certs_only` with bundled roots**.
- **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
160 changes: 160 additions & 0 deletions .claude/skills/forge-error-catalogue/references/failure-catalogue.md
Original file line number Diff line number Diff line change
Expand Up @@ -1369,6 +1369,71 @@ upstream drift becomes your build break. (From `recipes/pymupdf`.)

---

### bindgen: `'stdlib.h' file not found` (Android) / `version 'sim' in target triple 'arm64-apple-ios-sim' is invalid` (iOS simulator)

**Cause:** a `-sys` crate's build script runs bindgen, whose libclang gets no target
sysroot, and bindgen spells the `aarch64-apple-ios-sim` Rust triple as a clang triple
clang rejects.

**Fix:** forge now exports `BINDGEN_EXTRA_CLANG_ARGS_<cargo target>` with `--target=` and
the NDK sysroot or `-isysroot <SDK>` for every Rust build. If you still see it, the crate
passes its own conflicting clang args. Found on vl-convert-python (libnghttp2,
libuv-sys-lite).

---

### aws-lc-sys: `Failure invoking external bindgen` / `Please enable the 'bindgen' feature` (iOS, Android x86_64)

**Cause:** with its default `all-bindings` feature, aws-lc-sys 0.40 ships pregenerated
bindings for a fixed target list (Android arm64 is on it; no iOS target and not Android
x86_64 are) and otherwise wants the `bindgen` CLI.

**Fix:** enable its internal bindgen from the consuming crate, target-gated:
`[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]`
`aws-lc-sys = { version = "=0.40.0", features = ["bindgen"] }`. Newer aws-lc-sys (0.45)
uses universal bindings and needs nothing.

---

### iOS: `ld: library 'python3.10' not found` (a PyO3 extension with `abi3-pyXY`)

**Cause:** a pyo3 abi3 cross build links the abi3 floor (`-lpython3.10` for
`abi3-py310`), not the Python forge built for.

**Fix:** drop the `abi3-*` feature in the recipe patch; forge builds one wheel per Python
anyway. Android may link fine regardless, so do not take a green Android leg as proof.

---

### A crates.io dependency fails to compile for Android or iOS (`compile_error!` in a per-OS branch, `cannot find function` behind `cfg(target_os = "macos")`)

**Cause:** the crate covers macOS and Linux only. iOS takes neither branch; Android takes
Linux's where it says `any(target_os = "linux", target_os = "android")` and nothing
otherwise.

**Fix:** patch the crate with forge's `crate_patches` (see `new-mobile-recipe`). For iOS a
blanket `target_os = "macos"` -> `any(target_os = "macos", target_os = "ios")` over the
crate is usually right, since the branches select Darwin APIs; compile with
`cargo build --keep-going` to collect every failing crate in one pass. Precedent:
vl-convert-python (six Deno crates).

---

### A build script's own `cmake` call configures for the build host (no cross toolchain)

**Cause:** the crate shells out to `cmake` with a fixed argument list (v8x building WAMR),
so nothing forge sets reaches it, and CMake picks the host platform (WAMR even defaults
its platform to `CMAKE_HOST_SYSTEM_NAME`).

**Fix:** CMake reads `CMAKE_TOOLCHAIN_FILE` from the environment. Ship a toolchain file in
the recipe patch that keys off `$ENV{TARGET}` (the Rust triple cargo gives build scripts;
host builds match nothing and stay native), and set the variable for build scripts
through a patched-in `.cargo/config.toml`:
`[env] CMAKE_TOOLCHAIN_FILE = { value = ".cargo/cross.cmake", relative = true }`.
Precedent: vl-convert-python's `mobile.patch`.

---

## Runtime failures (on device/emulator/simulator)

### Flet 0.86 changed Android packaging — `sitepackages.zip` + jniLibs relocation (the umbrella behind a whole class of "worked under 0.85, fails now" on-device failures)
Expand Down Expand Up @@ -1773,6 +1838,90 @@ recipe that borrows this static-link pattern must apply the same per-platform ga

---

### `dlopen failed: cannot locate symbol "__clear_cache"` (Android **arm64** only, a Rust extension)

**Cause:** rustc links cdylibs with `-nodefaultlibs`, so the NDK's
`libclang_rt.builtins-<arch>-android.a` never reaches the link, and Rust's
`compiler_builtins` lacks some symbols that C code in `-sys` crates calls. arm64 libffi's
instruction-cache flush is the known one. The `.so` links (a shared library may keep
undefined symbols), then bionic refuses it at `dlopen`. x86_64 never references
`__clear_cache`, so **CI's x86_64 emulator cannot catch this**. Tell it from the jniLibs
collision above by the symbol: a compiler builtin, not one of your libraries'.

**Fix:** forge now appends the builtins archive to every Android Rust link
(`cargo_ldflags`, next to the libgcc shim); only members for still-undefined symbols are
pulled. If you still see it, check the archive glob matches the NDK layout. Found on
vl-convert-python.

---

### `fatal runtime error: out of TLS keys, aborting` (Android) — often in a module that is NOT the one using them up

**Cause:** Rust's Android targets have no native TLS (`has-thread-local` unset,
`tls-model: emulated` in `rustc --print target-spec-json`), so std backs every
`thread_local!` it touches with its own pthread key, and bionic allows a process about
128. Each Rust extension carries its own std. vl-convert's Deno worker took about 80, so
the next Rust module to load (rpds-py, under Altair) aborted the whole app. iOS has
native TLS and 512 keys and is unaffected.

**Measure it** before and after a suspect call, from Python on the device:
```python
libc = ctypes.CDLL("libc.so"); keys, k = [], ctypes.c_uint()
while libc.pthread_key_create(ctypes.byref(k), None) == 0: keys.append(k.value)
for key in keys: libc.pthread_key_delete(key) # len(keys) = keys still free
```

**Fix:** link a C shim into the extension that defines `pthread_key_create`,
`pthread_key_delete`, `pthread_getspecific` and `pthread_setspecific` with
`visibility("hidden")` and multiplexes them onto one real key holding a per-thread table
(destructors run at thread exit with POSIX iteration semantics). Hidden definitions bind
every reference inside the `.so` and nothing outside it. Link it `static:+whole-archive`,
since libc.so also defines the names. Precedent: `forge_tls.c` in
`recipes/vl-convert-python/patches/mobile.patch` (95 keys free afterwards, against 4).
Verify with `llvm-nm -D <so> | grep pthread_key`, which should print nothing.

Before copying it into another extension, check what creates keys there: the shim never
reuses a deleted ID, so it caps creates at 4,095 per process. That is fine for Rust std,
which deletes a key only when it loses a race to initialise one, and wrong for code that
creates and deletes keys per object. Reuse needs a generation per slot, as bionic has, so
a new key cannot read the deleted key's values. The real-key count above cannot see this
limit.

---

### `Failed to initialize a JsRuntime: No such file or directory (os error 2)` (a Rust crate embedding `deno_core`, on device)

Python only sees `Failed to receive worker startup status: receiving on a closed channel`
when the runtime lives on a worker thread; capture the panic (Diagnostic snippets).

**Cause:** deno_core 0.411's `include_js_files!` records each extension source as an
absolute **build-machine path** and reads it only while a snapshot is made. A runtime
started with `startup_snapshot: None` reads those paths at startup: they exist on the
machine that built the wheel (so a desktop test, or an iOS simulator on that machine,
passes) and not on a phone. deno_runtime attaches `99_main.js` the same way by hand.

**Fix:** patch the macros to `mode=included` and deno_runtime's `99_main.js` push to
`ascii_str_include!` (`crate_patches`). Check the binary:
`grep -a -o -E '/(home|Users)/[^"[:space:]]*\.(js|ts)' <so>` should find (almost) nothing.
Precedent: vl-convert-python's `deno_core.patch` and `deno_runtime.patch`.

---

### `Expect rustls-platform-verifier to be initialized` (Android panic on the first HTTPS request)

**Cause:** reqwest 0.13's `rustls` feature verifies certificates through
rustls-platform-verifier, which on Android calls into Java and needs
`init_hosted(env, context)` first. A Python extension never does that. The panic happens
at TLS-handshake time, so import and offline work look fine.

**Fix:** on Android, give the client its own roots so reqwest skips the platform verifier:
`ClientBuilder::tls_certs_only(...)` with `webpki-root-certs` converted to
`reqwest::Certificate`. Server certificates installed only on the device are then not
trusted; say so on the recipe page. Precedent: `MobileTls` in vl-convert-python's
`mobile.patch`.

---

### iOS: `import <pkg>` → `symbol not found in flat namespace '…<Sym>…'` at dlopen (a CMake OBJECT library never made it into the `.framework`)

**Cause:** the build is GREEN but a CMake `OBJECT` library's objects — meant to link
Expand Down Expand Up @@ -2328,6 +2477,17 @@ republishing". Full authoring guidance in `new-mobile-recipe` § 3.5b.

## Diagnostic snippets

### Capture a Rust panic on Android, and keep results when the app aborts

Android discards fd 2, so a Rust panic's message never reaches logcat, and a process
abort kills pytest before its failure summary reaches `console.log`. Redirect fd 2 and
write results to a file in the app's cache dir, then read both with `adb shell cat`:
```python
err = os.path.join(tempfile.gettempdir(), "stderr.txt") # the app cache dir
os.dup2(os.open(err, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644), 2)
os.environ["RUST_BACKTRACE"] = "1"
```

### Inspect a wheel's contents

```bash
Expand Down
9 changes: 9 additions & 0 deletions .claude/skills/local-recipe-testing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,15 @@ carries every control value: `xcrun simctl spawn "$UDID" log show --last 2m --st

**Two iOS builds running at once cause the same symptom.** Both stage into that one shared `dist_ios`, so whichever finishes second can bundle the other app's files (seen 2026-10-01: a recipe-tester `.app` carrying a consumer app's `main.pyc`). If anything else on the machine might be building for iOS — another terminal, another agent — give each build its own `PUB_CACHE=<dir>` (the first run re-downloads the pub packages), and pass the same `PUB_CACHE` to any manual `flutter build` that follows.

15. **CI's Android test runs only x86_64, so a green CI says nothing about arm64, which is every phone.**
vl-convert passed CI's emulator build while its arm64 `.so` could not even load
(`cannot locate symbol "__clear_cache"`: x86_64 never references it). Do a local arm64
pass for any Rust or C-heavy recipe before trusting the matrix. Two related traps:
an iOS simulator on the machine that **built** the wheel can pass while the binary
reads build-machine paths a phone will not have, and a native abort kills pytest
before its summary reaches `console.log`. For both, see the `forge-error-catalogue`
diagnostic snippets (capture fd 2, write results to a file).

## 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
22 changes: 22 additions & 0 deletions .claude/skills/new-mobile-recipe/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ Match the package to one of these shapes. Each maps to a template in `templates/
| 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 |
| **Rust crate embedding Deno (`deno_core`/`deno_runtime`)** | The package runs JavaScript in Deno, i.e. V8 (vl-convert). V8 has no Android build and a host-made V8 snapshot cannot cross configurations | No template — copy `recipes/vl-convert-python/`: deno_core's `quickjs` feature (QuickJS-ng via the `deno_v8` facade), no snapshot with the extension sources embedded, Deno cfg gaps through `crate_patches`. Read its Build notes first: two failures only show on an arm64 device |
| **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 |

If unsure, start with **minimal C-extension** and let the build tell you what's missing. Iterate up the table as failures surface.
Expand Down Expand Up @@ -358,6 +359,27 @@ Renders the meta.yaml for all SDK contexts (iphoneos, iphonesimulator, android)
with a non-matching needle silently no-ops. Always `assert needle in text` before
replacing, or grep the output file for the new content after.

### 3.5a — Patching a crates.io dependency (`crate_patches`)

`patches` only reaches the sdist; a Rust package's dependencies are fetched by cargo at
build time. To patch one, list it in meta.yaml:

```yaml
crate_patches:
deno_core: # or name@version when Cargo.lock holds several versions
- deno_core.patch # in patches/, applied with patch -p1 from the crate root
```

forge downloads the exact version the source's `Cargo.lock` resolves, checks its
sha256 against the lockfile, unpacks it into `forge-crates/`, applies the patches and adds
a `[patch.crates-io]` entry to the root `Cargo.toml`, joining upstream's table when it has
one. If that table already patches the same crate (say, to a git fork), forge stops: change
upstream's entry with the sdist patch instead. Generate each patch from a pristine
copy of `~/.cargo/registry/src/*/<crate>-<ver>` the same way as an sdist patch, and keep
the sdist patch from rewriting `Cargo.lock`: forge reads versions and checksums from it.
Running `cargo metadata` or `cargo tree` in your edited tree rewrites the lockfile, which
then lands in the diff.

### 3.5b — Licences (`flet-lib*` / build.sh recipes only)

A **Python package** carries its own licence: the build backend copies upstream's METADATA
Expand Down
Loading
Loading