build-native
Skill654 starsChanged 27 days agoMITView on GitHub
Rebuild native LiteRT-LM prebuilts for flutter_edge_ai (iOS / macOS / Linux / Windows / Android) — covers required build flags, the upstream commit pin, and a mandatory post-build verification checklist that catches the bugs we have already shipped at users.
- Pipes a download into a shell
- Deletes or force-pushes
2 other projects use a version of this.
Adopted by
Most recently taken up by
- utopia-rise/fmod-gdextensiontheir own version, first seen Nov 9, 2026
- utopia-rise/godot-kotlin-jvmtheir own version, first seen Oct 17, 2026
Other versions2 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 2
- utopia-rise/godot-kotlin-jvm★ 995same name
- utopia-rise/fmod-gdextension★ 954same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository6 other files it gives its agents.
- AGENTS.mdAGENTS.md
- ci-statusSkill
- ddp-device-testSkill
- releaseSkill
- review-prSkill
- upgrade-genkitSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
20 rules. About 0 are common, 5 are distinctive, and 15 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
15
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 74
If it moved, diff the headers the bindings use (litert/c/litert_{model,environment,options,tensor_buffer,compiled_model}.h) at both refs.
- Distinctive: line 164
Run the patcher against the new tag before building anything.
- Very specific: line 167
Every section must print OK.
- Very specific: line 237
Verify the LFS pull produced real binaries, not pointers — head -c 20 <file> starting with version https means it's still a pointer.
- Distinctive: line 243
All three dispatch libraries build from the same pin as the runtime:
- Distinctive: line 311
Only old LiteRT does that.
- Very specific: line 340
Always pass the pinned SHA explicitly — the scripts' DEFAULT_REF lags the release you're migrating to.
- Distinctive: line 390
Every freshly-built dylib must pass all of these checks.
- Very specific: line 439
Run this for every dylib in prebuilt/<dir>/, not just the one you rebuilt.
- Very specific: line 466
Every native-v0.18.0 / -a Apple binary prints libLiteRtGpuAccelerator.dylib.
- Very specific: line 477
Run nm -gU | grep -c '_litert_lm_' — expect ~50 symbols (matches bindings.dart lookupFunction count).
- Very specific: line 561
After the release exists and the hook's checksums are updated, force the real path:
- Very specific: line 581
After flutter build ipa --release or archive:
- Very specific: line 591
If anything other than .framework/ is in there, App Store will reject with ITMS-90432 ("Unexpected file found in Frameworks").
- Very specific: line 782
Check first, before spending minutes: getprop ro.soc.model must say SM8750 (platform sun) or SM8850 (platform canoe) — whichever SoC the …
- Very specific: line 803
Verify the entrypoint actually landed with binary grep — strings misses it in the compiled kernel blob:
- Distinctive: line 860
Every one of those would have been caught by checks 1-10 before commit.
- Very specific: line 877
Don't ship them blindly: Run check #4 (install_name_tool smoke) on every upstream-sourced dylib before copying to prebuilt/.
- Very specific: line 885
Do NOT try to commit the dylibs — prebuilt/ is gitignored (.gitignore /packages/flutter_edge_ai_litertlm/native/litert_lm/prebuilt/) and …
- Very specific: line 887
Run dart pub publish --dry-run — must show 0 warnings.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Build native dylibs for flutter_edge_ai — the right way
This skill exists because we shipped 0.14.0 and 0.14.1 with native dylibs that broke real users (App Store rejection in 0.14.0, install_name_tool headerpad failure in 0.14.1, x86_64-not-arm64 in upstream prebuilts). Every one of those was caught after publish. The verification checklist below would have caught all of them locally.
The v0.16.0 migration added a second class of defect, and it is the more expensive one: inputs we pinned once and stopped maintaining — a Bazel define upstream had deleted, an OpenVino SDK four minor versions behind the pin, a dispatch library hardcoded to a LiteRT commit from four releases ago. None of them fails a build. All of them fail at a version boundary, which makes them look exactly like upstream regressions — and we filed two upstream issues that had to be retracted before finding that out.
Read top to bottom before rebuilding anything. The checklist is non-optional.
Every path below is relative to the repo root. This is stated because it was
not true for a long time: the paths in this file predated the 1.0 monorepo split
and pointed at native/litert_lm/…, which does not exist from the root. nm and
otool on a missing file print nothing, grep -c on nothing returns 0, and the
whole verification checklist read as passing while touching no artifact at all —
the same defect the checklist exists to catch, applied to itself. When you add a
command here, run it from the root first and confirm it produces output.
Use /usr/bin/grep when you paste a check into an interactive shell. In the
agent's zsh, grep is a shell function over a ripgrep-style search that skips
binary files and hidden files. grep -q LogitMask <provider> then answers "no"
for a provider that has it (a false ABI MISMATCH at the v0.18.0 bump), and a
recursive search never opens .bazelrc. The build scripts are unaffected —
they run under bash, which does not inherit zsh functions.
Pre-build setup
Required upstream commit
The current build-script defaults target LiteRT-LM 0.19. To reproduce the
immutable native-v0.18.0-h archives, use the repository state from that tag;
the current Dart client still calls stream_proxy_create2 and therefore
matches only the 0.18 archives until PR B lands.
Pin the exact tag commit SHA on every platform, and pass it explicitly. Upstream has retagged releases in place before, so a tag name is not a stable reference. Get the SHA with:
gh api repos/google-ai-edge/LiteRT-LM/git/ref/tags/<tag> --jq '.object.sha'The scripts' DEFAULT_REF lags whatever release you are migrating to, so never rely on it — build_ios.sh still defaulted to the v0.14.0-era 80f301ff during the v0.15.0 migration.
The historical iOS 5e0d86b pin is obsolete. It existed because 5e0d86b was the first commit shipping libLiteRtMetalAccelerator.dylib for iOS, and older tags produced a libLiteRtLm.dylib whose Metal-accelerator vtable didn't match the prebuilt framework → EXC_BAD_ACCESS inside litert_lm_engine_create on device. Modern tags ship their own prebuilt/ios_arm64/, so building iOS from the tag is now the safer option.
The real invariant is: source and accelerator prebuilts must come from the same tree. Mixing them is the hazard — not the choice of tag. Verify before building:
gh api "repos/google-ai-edge/LiteRT-LM/contents/prebuilt/ios_arm64?ref=<tag>" --jq '.[].name'
# needs libLiteRtMetalAccelerator.dylib + libLiteRtTopKMetalSampler.dylibThe LiteRT pin is a second ABI surface — check it every time
A LiteRT-LM bump silently moves LITERT_REF in WORKSPACE (line ~6), and that is a different upstream repo with its own C API. This matters because three separate things bind to it:
flutter_edge_ai_litertlm→ LiteRT-LM C API (c/engine.h)flutter_edge_ai_speech→ LiteRT C API directly, via litertlm'slib/src/ffi/litert_bindings.dart- both NPU dispatch libraries → the
LiteRtDispatchApistruct and the LiteRT runtime they are loaded into
So a green litertlm smoke run proves nothing about embeddings, and nothing at all about NPU. In the v0.14.0 migration the pin moved, LiteRtCreateModelFromFile gained a third parameter (LiteRtEnvironment first), and embeddings silently returned status=500 — a full day lost before the cause was found.
for t in <old-tag> <new-tag>; do
gh api "repos/google-ai-edge/LiteRT-LM/contents/WORKSPACE?ref=$t" --jq '.content' | base64 -d | grep '^LITERT_REF'
doneIf it moved, diff the headers the bindings use (litert/c/litert_{model,environment,options,tensor_buffer,compiled_model}.h) at both refs. Symbol list — the hand-written bindings moved into the litertlm package, so the
old embeddings-only grep now misses more than half of them (23 vs 52 symbols):
grep -rohE "LiteRt[A-Za-z_]+" \
packages/flutter_edge_ai_litertlm/lib/src/ffi/litert_bindings.dart \
packages/flutter_edge_ai_speech/lib/src/litert/ | sort -uDiff the structs too, not only the function signatures. A struct we mirror by hand (LiteRtLayout, LiteRtRankedTensorType) can change layout while every symbol and signature stays the same, so the symbol list above passes. LiteRT d84656955 (2026-07-09) turned bool has_strides : 1 into unsigned int has_strides : 1, which moved dimensions[] from offset 8 to 4 under MSVC only; our Windows-only mirror kept offset 8 and broke Windows embeddings and speech from native-v0.16.0 on (pitfall #16). Diff litert/c/litert_layout.h and litert/c/litert_model_types.h at both refs, and when a divergence we work around disappears upstream, delete the workaround in the same bump. test/ffi/litert_layout_abi_test.dart pins the current sizes.
Known pins: v0.14.0 → 622f1f3c (breaking for embeddings; predates d84656955); v0.15.0 → 3cb830ad (safe — four headers byte-identical, litert_compiled_model.h only gains LiteRtGetCompiledModelEnvironment()); v0.16.0 → 0ff28117f1cb5556d0e015bf80b773f74e2bee51 (contains d84656955); v0.17.0 → 9fe5be45564c868408e6514c8aabb83e211a0911 (headers additive; the GPU samplers' Create gained a leading runtime_c_api argument, prebuilt samplers refreshed upstream in a24911058); v0.18.0 → 26895c9fbcc25c43faa8c1a98cd1fd28951602c3 (the seven binding headers byte-identical, litert_common.h / litert_tensor_buffer_types.h comment-only; deletes resolve_symbols_in_exec in fd031ae5, see below).
Never hardcode this ref in a build script. build_qualcomm_dispatch.sh carried a literal 5c5b9ce6 from the native-v0.12.0 era and nobody noticed for four releases, because a stale dispatch library does not fail politely — see the NPU section below. Derive it from the WORKSPACE of the LiteRT-LM revision being built:
LITERT_REF="$(curl -fsSL \
"https://raw.githubusercontent.com/google-ai-edge/LiteRT-LM/$LITERTLM_REF/WORKSPACE" \
| sed -n 's/^LITERT_REF *= *"\([0-9a-f]*\)".*/\1/p' | head -1)"
[ -n "$LITERT_REF" ] || { echo "could not resolve LITERT_REF" >&2; exit 1; }The empty-result guard matters: curl | sed on a moved/renamed WORKSPACE yields an empty string, and an empty --define or checkout ref fails much later and much less legibly than an explicit exit 1 here.
Bazel defines rot — confirm each one still exists in the tree
A --define that upstream has deleted is not an error. Bazel accepts any --define=key=value; if no config_setting reads that key, the flag is silently inert and you get the default build.
We passed --define=litert_link_capi_so=true on Windows and Linux for three releases. Upstream removed the name; no config_setting reads it in the v0.16.0 tree (a plain grep still finds one commented-out mention in .github/workflows/ci-build-mac.yml, which is why the recipe below greps only .bzl/BUILD/.bazelrc). The live name is litert_runtime_link_mode=dynamic. This was harmless until v0.14.0 split Dawn out of the WebGPU accelerator, from which point the statically-linked runtime is exactly what crashed GPU engine_create — reported upstream as #2957 and retracted, because the cause was ours.
Before trusting any define in a build command, grep both trees it can live in — LiteRT-LM, and LiteRT at the derived LITERT_REF (a define read only by @litert never appears in the LiteRT-LM tree, so grepping that alone reports "dead" for a live flag and "dead" for a dead one alike):
# Query the LiteRT-LM workspace, not the repo root (not a Bazel workspace), and
# only after a build/fetch at this pin — otherwise LR points nowhere and every
# define reads litert=0.
LR="$(cd /tmp/LiteRT-LM && bazelisk info output_base 2>/dev/null)/external/litert"
[ -d "$LR/litert" ] || echo "LiteRT not fetched at $LR — run a build or 'bazelisk fetch' first"
for d in litert_runtime_link_mode <any-other>; do
printf "%-32s lm=%s litert=%s\n" "$d" \
"$(/usr/bin/grep -rl "$d" /tmp/LiteRT-LM --include='*.bzl' --include='BUILD*' --include='.bazelrc' --include='*.bazelrc' 2>/dev/null | wc -l)" \
"$(/usr/bin/grep -rl "$d" "$LR" --include='*.bzl' --include='BUILD*' 2>/dev/null | wc -l)"
doneZero in both means the flag is dead — find the replacement in docs/getting-started/build-and-run.md, do not leave it in "just in case". The one current desktop define is documented there as mandatory for GPU:
| Define | Why |
litert_runtime_link_mode=dynamic | Keeps the LiteRt C API out of libLiteRtLm so it resolves against the separately shipped libLiteRt at runtime — which is what the prebuilt WebGPU accelerator and the split libwebgpu_dawn expect. |
resolve_symbols_in_exec=false used to sit in this table. LiteRT fd031ae5 (2026-08-28, inside the v0.18.0 pin) deleted the flag together with every select that read it and hard-wired the branch =false picked (the DLL import library on Windows), so dropping it changes nothing in the build. It was removed from both workflows at the v0.18.0 bump.
Required linker flags
| Platform | Flag | Why |
| macOS / iOS | -Wl,-headerpad_max_install_names | Native Assets re-runs install_name_tool -id @rpath/... on every pub get. Without headerpad, the rewrite fails with "larger updated load commands do not fit" and pub get aborts. |
| macOS | -mmacosx-version-min=11.0 | Without it clang stamps LC_BUILD_VERSION with the build host's OS, so the bundle's floor silently tracks whichever Mac produced it. |
| Android | aarch64-linux-android24-clang (target triple carries the API level) | Same reason, stated explicitly rather than inherited. |
| Linux | -Wl,--dynamic-list=... (already in patch_c_api.sh §1) | Exports LiteRt* / litert_lm_* symbols for the WebGPU accelerator's dlsym(RTLD_DEFAULT) |
| Windows | /DEF:windows_exports.def (already in patch_c_api.sh §1) | Bazel native attribute |
| All | -fvisibility=hidden is WRONG for our use case — needs default visibility on the listed export sets |
The headerpad_max_install_names flag is not optional on Apple platforms. Verify in the post-build checklist below.
Deployment target must be stated, never inherited. This bites only the libraries we compile — the upstream prebuilt companions arrive with their own sane values (11.0 / 14.0), so a single odd number in the middle of an otherwise healthy bundle is the signature. native-v0.14.0 shipped libStreamProxy.dylib with minos 26.0 for exactly this reason: build_macos.sh compiled it with a bare clang -shared on a macOS 26 host, while example/macos/Podfile advertises platform :osx, '10.15'. build_ios.sh never had the bug because its step 8b force-normalizes every companion to 13.0 with vtool -set-build-version. Check every self-built binary:
for f in prebuilt/macos_arm64/*.dylib; do
printf "%-46s minos=%s\n" "$(basename $f)" "$(vtool -show-build "$f" | awk '/minos/{print $2; exit}')"
doneAnything at or near the current macOS release means the flag was dropped. Rebuilding one dylib is a two-second clang call — no Bazel involved — so there is never a reason to ship it wrong.
Patches we apply
packages/flutter_edge_ai_litertlm/native/litert_lm/patch_c_api.sh — the surviving sections (2–9 were deleted at the v0.14.0 migration once upstream implemented them natively; don't go looking for them):
cc_binary(linkshared=True)target + Linux dynamic-list + Windows .def 4b.set_use_hw_masking_for_npu(Intel LunarLake/PantherLake — defaulttruecrashes their NPU) 4c. GPU smooth-UI knobs —gpu_context_low_priority+kernel_batch_size(#364) 10b. App Store ITMS-90432 fix — rewritegpu_registry.ccdlopen to@executable_path/...framework/<X>on Apple. iOS-critical. Pluspatches/litert_gpu_registry_apple.patch(since native-v0.18.0-b), added to thelitertarchive aspatches=: on Apple our framework is the ONLY GPU accelerator tried, and theRTLD_DEFAULTfallback is off — see "coexisting LiteRT runtimes" below.- minizip/zlib mirrored off the flaky zlib.net
- Windows Intel NPU —
patches/windows_no_fd_model_loading.patch:.litertlmsections load buffer-backed, never through a C runtime descriptor (LiteRT-LM #4023). Without it every AOT NPU bundle endsengine_createinlibLiteRt.dll(regression #20 below). The section also fails when a newCreateFromFdcaller appears inruntime/orc/. - Stopped-turn rewind —
patches/conversation_rewind_cancelled_turn.patch: a checkpoint before each send's own prefill, each send's end recorded in the C wrapper, andlitert_lm_conversation_rewind_cancelled_turn, which cancels, waits for the session's tasks, then rewinds a cancelled turn and prefills it back. Without it the Dart client rebuilds a stopped chat from text and loses earlier images and audio;verify_tarball_manifest.shrefuses a tarball without its export and contract.
§10a (sampler_factory.cc) is deliberately NOT patched — libLiteRtTopKMetalSampler.dylib has 3-of-7 broken exports on Apple (#2073). Patching it surfaces a NULL-vtable crash. Leave the basename dlopen so sampler_factory.cc falls back to CPU sampler. Revisit when Google fixes #2073.
Run the patcher against the new tag before building anything. There is no dry-run mode — it edits the tree in place, so run it on a throwaway clone.
Every section must print OK. A WARN means a patch target moved and that
section did not apply — and the script still exits 0, so nothing downstream
notices. Sections 2-9 died exactly this way and we only found out months later,
when the sampler stopped honouring per-session parameters. §10b is the App Store
ITMS-90432 fix (pitfall #2, already shipped to users once): if its WARN fires,
the Metal accelerator path is silently unpatched and iOS GPU breaks on device.
Make it fail instead of trusting yourself to read the log:
LOG=/tmp/patch_c_api.log
bash packages/flutter_edge_ai_litertlm/native/litert_lm/patch_c_api.sh /tmp/LiteRT-LM 2>&1 | tee "$LOG"
grep -q WARN "$LOG" && { echo "PATCH INCOMPLETE — a target moved, do not build"; exit 1; }
# Assert the artifact, not the log line. gpu_registry.cc lives in LiteRT, not
# in this tree — §10b patches WORKSPACE so Bazel rewrites it at fetch time.
grep -q FLUTTER_GEMMA_GPU_REGISTRY_PATCH /tmp/LiteRT-LM/WORKSPACE \
|| { echo "§10b did not apply — iOS GPU would ship broken"; exit 1; }That proves the patch is queued, not that it compiled in — post-build check #5 (the @executable_path string inside the dylib) is what proves that. At v0.18.0 the WARN fired for real: upstream rewrote the litert patch_cmds entry our anchor matched, and the script exited 0. §10b now anchors on the patch_cmds = [ list of the litert archive itself.
Coexisting LiteRT runtimes (litertlm 1.10.0 → native-v0.18.0-b). An app can
hold a second LiteRT: flutter_litert ≥ 3.4 embeds its own LiteRt.framework
and LiteRtMetalAccelerator.framework (LiteRT 2.1.5 at 3.9.3). Upstream
gpu_registry.cc tries the generic libLiteRtGpuAccelerator first, and when a
dlopen fails it calls dlsym(RTLD_DEFAULT, "LiteRtRegisterGpuAccelerator") —
the first such symbol in load order. flutter_litert's accelerator is linked at
launch, ours is dlopened later, so the fallback registered their
accelerator into our environment and the app crashed in
LiteRtCreateCompiledModel. The rename to LiteRtLmMetalAccelerator (§10)
fixed the framework-name collision and is what exposed this: 1.10.0 shipped
it, crashed on the first iPhone that also ran a flutter_litert GPU model, and
the rename was never run in that app before publishing. The patch is a real
diff, not one more sed: Bazel applies patches before patch_cmds and fails
the fetch when a hunk does not apply, which is the loud failure a moved
upstream line has to produce. patch_c_api.sh refuses a tree an older
patcher already touched (sed without the diff), and build_{ios,macos}.sh
refuse a libLiteRtLm.dylib that still names libLiteRtGpuAccelerator or the
bare LiteRtMetalAccelerator. The one test that proves it is the app with
flutter_litert on a real iPhone (litert_hackathon --selftest: detector GPU,
Gemma GPU, detector again) — the console must show
Dynamically loaded GPU accelerator(@executable_path/Frameworks/LiteRtLmMetalAccelerator.framework/LiteRtLmMetalAccelerator) registered
as the FIRST attempt.
On Windows/git-bash, python3 resolves to the Microsoft Store stub, which prints "Python was not found" and still exits 0 — so sections 10b and 11 silently no-op. Shim it first:
printf '#!/bin/sh\nexec "<path>/python.exe" "$@"\n' > /somewhere/python3 && chmod +x and prepend that dir to PATH.
Cloning upstream without fighting the network
The repo carries large LFS binaries; a full clone repeatedly dies mid-transfer on anything but a rock-solid link, and the failure restarts from zero. Clone the tag shallow with LFS deferred, then pull only the platforms you're building:
GIT_LFS_SKIP_SMUDGE=1 git -c http.version=HTTP/1.1 -c http.postBuffer=524288000 \
clone --depth 1 --branch <tag> https://github.com/google-ai-edge/LiteRT-LM /tmp/LiteRT-LM
cd /tmp/LiteRT-LM
git lfs pull --include="prebuilt/macos_arm64/*,prebuilt/ios_arm64/*,prebuilt/ios_sim_arm64/*,prebuilt/android_arm64/*"Then point origin at a local bare mirror. build_*.sh run git fetch --tags --force origin whenever the clone dir already exists — cheap on a full clone, near-endless on a shallow one, because every other tag's objects have to be fetched. Redirecting origin makes that fetch local and instant (measured: 0.03 s), and removes the network from the build loop entirely:
git clone --bare /tmp/LiteRT-LM /tmp/LiteRT-LM-mirror.git
cd /tmp/LiteRT-LM && git remote set-url origin /tmp/LiteRT-LM-mirror.git
# LFS follows origin, and the bare mirror has no LFS endpoint — pin it back:
git config lfs.url https://github.com/google-ai-edge/LiteRT-LM.git/info/lfsThat last line is not optional. Redirecting origin fixes the git fetch --tags hang and immediately breaks git lfs pull, which then leaves pointer files in place of the accelerator dylibs — a failure that surfaces much later, as a dylib that loads but exports nothing.
Verify the LFS pull produced real binaries, not pointers — head -c 20 <file> starting with version https means it's still a pointer.
⚠️ NPU dispatch libraries MUST be rebuilt from the pin — never carried forward
This section previously said the opposite. It claimed both NPU stacks are unbuildable and must be copied from the previous release, and instructed asserting byte-identity against it. That was wrong, and it is the single most expensive mistake this skill has recorded: it turned two of our own configuration defects into upstream bug reports (#2957, #3217) that had to be retracted, and it is the direct cause of the Android NPU crash below.
All three dispatch libraries build from the same pin as the runtime:
| Bundle | Target | Notes |
windows_x86_64 | @litert//litert/vendors/intel_openvino/dispatch:LiteRtDispatch | Bazel fetches the OpenVino SDK itself (configurable_repo) — no preinstalled toolkit on the runner. Built in CI. |
android_arm64 | //litert/vendors/qualcomm/dispatch:dispatch_api_so | From the LiteRT repo at the derived LITERT_REF, not LiteRT-LM. Bazel auto-downloads the QAIRT that LiteRT's third_party/qairt/workspace.bzl pins (~500 MB) — 2.44 at v0.16.0, 2.47 at v0.17.x, 2.50.0.260828 at v0.18.0 — or point LITERT_QAIRT_SDK at a local copy of that same version. The script fails unless the SDK's sdk.yaml version equals the hook's qnnRuntimeVersion (see below). Built locally by build_qualcomm_dispatch.sh. |
linux_arm64 (since native-v0.18.0-c) | the same target, TARGET=linux_arm64 | Built in CI by the build-linux-arm64-qualcomm-dispatch job (ubuntu-22.04-arm, clang-17 — clang 14 fails on std::source_location), from a standalone LiteRT checkout where the script drops the Android-only -Wl,-lc++abi linkopt. Never downloads the 2.6 GB SDK: fetch_qairt_sdk_slim.py reads the headers, sdk.yaml and two host libs out of the zip by HTTP range and checks them against a pinned SHA-256 before writing. Also emits our libcdsprpc.so shim (empty, NEEDED libcdsprpc.so.1, SONAME libcdsprpc.so). Run the job alone with qualcomm_dispatch_linux_only: true; a full release packs both files into linux_arm64. The QNN runtime is never built or shipped: the app's hook reads it from the QAIRT zip (lib/src/hook/qairt_linux.dart). |
Why the old rule was believable and still wrong: a fresh LiteRT-LM build genuinely emits neither library, because neither lives in the LiteRT-LM tree — they are LiteRT vendor targets. "Absent from the output" was read as "unbuildable" instead of "wrong target".
A stale dispatch library does not fail politely. It is a plugin loaded into the runtime's address space and it shares the runtime's structs:
- Windows. Ours had been frozen since native-v0.11.0-b and shipped OpenVino 2026.2.0, while every LiteRT pin since has required 2026.3.0.dev20260622 (build 22242-561fc907ca4). Upstream's own
third_party/intel_openvino/openvino.bzlwarns that a mismatched pair leaves the Intel OV compiler plugin talking to the wronglibopenvino_intel_npu_compilerat runtime. That is what killedengine_createonbackend=npu. - Android.
libLiteRtDispatch_Qualcomm.sowas built against LiteRT5c5b9ce6. On v0.16.0 it SIGSEGVs insideLiteRtDestroyOptionsthe momentengine_createtears an options object down — a struct-layout mismatch, surfacing as a crash in the runtime's symbol, which reads as a runtime bug.
Both looked like clean regressions at a version boundary, because they were: the boundary is where upstream finally touched the code the frozen artifact depended on. Verify a bisection's conclusion, not just its measurement — "v0.13.1 works, v0.14.0 crashes" is a true measurement that says nothing about whose change is at fault when one input to the comparison never moved.
The OpenVino runtime half is now asserted, in both Windows workflows. They read the build string the SDK declares in runtime/version.txt and compare it against the string embedded in the openvino.dll being staged, throwing on mismatch — and throwing just as loudly when either side is unreadable or malformed, because a check that passes on missing data is indistinguishable from one that verified something.
Be precise about what that does and does not buy, because the first version of this note was not:
- It asserts internal consistency, not the pin. Both sides come out of the same Bazel-fetched
$ovRoot, so no input can currently make it fail — only a future edit that stages either side from somewhere else. That is anti-regression value, not detection of an upstream SDK bump. - It does not cover the dispatch.
LiteRtDispatch.dllis copied frombazel-binand embeds no version string, so the half of the pair that actually broke in #3217 is still only verified by check #9 on real silicon. - It cannot catch a hand-staged bundle.
Copy-Item -Forceoverwritesartifacts\from$ovRootimmediately before the check, so anything placed there by hand is gone before it could be tested.
Put it in both workflows. build-litertlm-native-windows.yml is fast iteration and ends at upload-artifact; build-litertlm-native.yml carries the publish-release job that creates the native-v* tag users fetch. A check added only to the first protects nothing that ships — which is exactly the mistake this paragraph originally shipped with.
Android: since native-v0.18.0 the QNN runtime is NOT in the tarball. Qualcomm licenses the QNN libraries for redistribution only inside an application, so the android_arm64 bundle carries the dispatch alone. An app opts in with hooks: user_defines: flutter_edge_ai_litertlm: qualcomm_npu: true (in the app's root pubspec, or the workspace root for a workspace member), and the litertlm hook fetches com.qualcomm.qti:qnn-runtime from Maven Central — version and sha256 pinned in lib/src/hook/qnn_runtime.dart, Skels raised to 16 KB there (prepareQnnLibrary), promoted into <cacheBase>/qnn/android_arm64/<version>-<sha8>-p<format>/. build_android.sh deletes any QNN .so left in prebuilt/android_arm64/ and verify_tarball_manifest.sh lists the ten as INTENTIONAL_DROPS. Do not put them back in the tarball.
The QNN runtime and the dispatch are still one matched pair. The dispatch negotiates an API version with the runtime it finds, and a stale runtime does not fail politely — ours once drifted into
E/litert: [qnn_manager.cc:349] Qnn System library version 1.8.0 is mismatched.
The minimum supported version is 1.11.0.
E/litert: [dispatch_api.cc:139] Failed to set up QNN manager
E/litert: [dispatch_delegate.cc:131] No usable Dispatch runtime foundSo bumping the LiteRT pin is a two-file change whenever its third_party/qairt/workspace.bzl moves: build_qualcomm_dispatch.sh reads the Bazel-fetched SDK's sdk.yaml version (2.50.0 at v0.18.0) and refuses to build unless it equals qnnRuntimeVersion in lib/src/hook/qnn_runtime.dart. Bump that constant and qnnRuntimeSha256 to the matching Maven release first (curl -fsSL https://repo1.maven.org/maven2/com/qualcomm/qti/qnn-runtime/<v>/qnn-runtime-<v>.aar | shasum -a 256, and compare its .sha1 with Maven's own). The release number is the only pairing that holds: QNN carries two independent numberings, and libQnnSystem.so — the file that broke — has no version string at all, so never try to pair them by strings | grep.
libQnnHtpV79Skel.so must stay in the hook's list: it is absent from /vendor/dsp/cdsp/ even on Qualcomm reference firmware, so shipping it is required, not a workaround for unusual devices.
Assert the dispatch per bundle before packing — build_qualcomm_dispatch.sh promotes exactly one file on Android and two (dispatch + shim) on Linux; verify_tarball_manifest.sh requires both in linux_arm64 and forbids libQnn* there. Shipping without it fails no build and no CPU/GPU smoke test — only a user on PreferredBackend.npu finds out. The on-device run (check #10) is still the only coverage of the pair working together.
Windows CI specifics
- Job timeout 150 minutes. A cold Bazel cache plus the OpenVino fetch overruns anything shorter; 60 min was killing runs mid-link.
- Exclude the Bazel output base from Defender. Two consecutive runs died at ~52 min with
Couldn't delete action output directory: D:/b/execroot/litert_lm/bazel-out/_tmp/actions/stderr-NNNN (Permission denied). The build had finished; Defender was holding a per-action stderr file open during cleanup.Add-MpPreference -ExclusionPathon the output base, plus one warm-cache retry of the build step, turns a 52-minute loss into a two-minute one. Allow-list the OpenVino components; never copy
runtime\binwholesale. A blanket copy drags in the CPU/GPU/auto/hetero plugins, every model frontend, and a full parallel set of debug builds — and those are namedopenvinod.dll,*_plugind.dll,*_frontendd.dll, so a_d.dllfilter misses all of them. Two near-identically named sets side by side is precisely how the OpenVino core binds the wrong plugin. The allow-list took the bundle from 45 files to 28 (253 MB uncompressed, 93 MB packed). Sincenative-v0.18.0-git also names the VM runtime OpenVINO 2026.4 loads by name:openvino.dll, openvino_intel_npu_plugin.dll, openvino_intel_npu_compiler.dll, openvino_intel_npu_compiler_loader.dll, openvino_intel_npu_vm_runtime.dll, openvino_tensorflow_lite_frontend.dll, tbb*$ovWantedin both workflows andintelNpuLibs(npu_stacks.dart) are one set: a DLL staged by CI but absent fromintelNpuLibsreaches the cache and never the app, which is what happened to the VM runtime beforenpu_build_consistency_test.dartcompared them.- The OpenVINO release is pinned twice over, on purpose.
patches/litert_openvino_2026_4_1.patch(upstream LiteRT991d15588a, applied bypatch_c_api.sh§10b as thelitertarchive'spatches=) movesopenvino_version.bzlto 2026.4.1 on our pin, because Intel's Gemma 4 12B for Panther Lake carries blob metadata 2.7, which the 2026.3 NPU plugin refuses. The workflows then require$ovRelease(2026.4.1-22982) and a sha256 per staged OpenVINO/TBB DLL ($ovSha256, the exact staged set, so a renamed TBB fails too): upstream'sopenvino.bzlfetches the SDK by URL with no sha256, and anOPENVINO_NATIVE_DIRon the runner (itslocal_path_env) would bypass the patch, which the step also refuses.npu_build_consistency_test.dartholds$ovReleaseto the patch and the$ovSha256names tointelNpuLibs. On an OpenVINO move, change all three together, rerunpython3 -I native/litert_lm/build_notices.py(itsOPENVINO_VERSION/OPENVINO_ROOT; NOTICES carries the toolkit's licence texts) and drop the patch once the LiteRT pin contains991d15588a— its preimage stops matching and the fetch fails, which is the reminder. Hazard for the next LiteRT pin: upstream
3560a7fb4d(2026-10-08, "Stage weight bank in chunks mapped from the model fd") stages the weight bank from the model's file descriptor, and on Windows that descriptor belongs to another C runtime thanLiteRtDispatch.dll's:75263fba05(2026-10-21, "Stage NPU weight bank from memory on Windows to fix 0xC0000409 crash", related to LiteRT-LM#4023) goes back to staging from memory there. Every bundle that stages a bank takes that path (Intel's 12B; EmbeddingGemma 2_Intel_PTLstages one too, not run against3560a7fb4d). A pin with the first and not the second brings the crash back; check both withgh api repos/google-ai-edge/LiteRT/compare/<pin>...3560a7fb4dbefore moving. Our pin stages from memory, which keeps the 12B's whole 5.55 GB pool resident for the model's lifetime.TBB lives outside
runtime\bin(3rdparty\tbb\bin), so search the whole SDK.throwon any missing member — a silently short NPU stack is the failure mode this whole section exists to prevent.The
tbb*wildcard at the end of that list is itself a small leak: it also matchestbb12_debug.dll,tbbbind_2_5_debug.dll,tbbmalloc_debug.dllandtbbmalloc_proxy_debug.dll. Only ~1.8 MB of a 253 MB bundle, and unlikeopenvinod.dllthe names are distinct enough not to be mis-bound, so it is dead weight rather than a hazard — but add-and $_.Name -notlike '*_debug.dll'next time the workflow is touched.
Qualcomm dispatch: -gcc-toolchain means you are building the wrong ref
clang: error: unknown argument: '-gcc-toolchain' looks like an NDK-version problem and is not one. It means the tree you are building resolves the Android toolchain through the legacy --crosstool_top=//external:android/crosstool, which wants the NDK ≤ r22 layout (toolchains/aarch64-linux-android-4.9/) that no modern NDK ships.
Only old LiteRT does that. Compare the two pins:
5c5b9ce6 (our old hardcoded ref) | 0ff28117 (the v0.16.0 pin) | |
| WORKSPACE | no android_ndk_repository at all | rules_android_ndk + register_toolchains |
build:android_arm64 | platforms only | adds --incompatible_enable_cc_toolchain_resolution and --incompatible_enable_android_toolchain_resolution |
| NDK 26+ | unsupported | supported (# Need latest rules_android_ndk to use NDK 26+) |
So the -gcc-toolchain failure was a symptom of the hardcoded LITERT_REF, not a separate constraint: the script was compiling a four-release-old tree that genuinely cannot use a modern NDK. Deriving the ref fixes the crash and the build. NDK r29 is correct here — same value build_android.sh requires, no per-invocation juggling.
Feed the NDK path with --repo_env, not --action_env:
bazelisk build --repo_env=ANDROID_NDK_HOME="$ANDROID_NDK_HOME" \
--config=android_arm64 --compilation_mode=opt --strip=always \
--linkopt=-Wl,-z,max-page-size=16384 \
//litert/vendors/qualcomm/dispatch:dispatch_api_soThe new wiring gates on a repository rule — check_android_ndk_env calls ctx.getenv("ANDROID_NDK_HOME") and, when unset, registers @android_ndk_env//:all, a repo with an empty BUILD file. Zero toolchains, and the failure surfaces far from its cause. --action_env populates action environments, which repo rules never read; the generated .litert_configure.bazelrc (--action_env ANDROID_NDK_HOME, ANDROID_NDK_API_LEVEL, …) is the old TF-era mechanism and is dead weight at this pin — api_level now lives in the WORKSPACE call.
Expect ~3 minutes warm and a 707-724 KB stripped ELF 64-bit aarch64 exporting exactly one symbol, LiteRtDispatchGetApi. Verify that symbol before packing; the library has no other public entry point, so its absence is the whole failure mode.
bazelisk clean --expunge between attempts only if you changed WORKSPACE-level wiring — Bazel caches toolchain resolution, and a cached failure re-prints the previous error without recompiling, which reads as "the fix didn't work".
Build commands
Always pass the pinned SHA explicitly — the scripts' DEFAULT_REF lags the release you're migrating to.
REF=<tag-commit-sha> # v0.17.0 = e9fd8c53ff968071774206163027dd84bedfe925 (v0.16.0 = 924e79c9…)
export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/29.0.14206865" # r29 mandatory
packages/flutter_edge_ai_litertlm/native/litert_lm/build_macos.sh "$REF" # macOS arm64
packages/flutter_edge_ai_litertlm/native/litert_lm/build_ios.sh "$REF" # iOS device + simulator
packages/flutter_edge_ai_litertlm/native/litert_lm/build_android.sh "$REF" # Android arm64, cross-compiled
# Qualcomm NPU dispatch — separate build, separate repo (LiteRT), separate NDK.
# Derives LITERT_REF from the LiteRT-LM WORKSPACE; see the NPU section above.
LITERTLM_REF="$REF" packages/flutter_edge_ai_litertlm/native/litert_lm/build_qualcomm_dispatch.shANDROID_NDK_HOME must be exported explicitly: build_android.sh auto-detects the newest NDK only when the variable is unset, so a stale value in your shell silently selects the wrong toolchain (r26 fails on a C++20 concept in the minijinja chat template).
Linux x86_64/arm64 and Windows x86_64 build in CI, not on a VM: dispatch build-litertlm-native.yml with -f litertlm_version=<SHA>. Use --ref <branch> if the migration carries source changes — the workflow compiles stream_proxy.c from the checked-out tree and builds the Intel NPU dispatch, so dispatching from main would silently ship a stale shim and a stale dispatch.
build-litertlm-native-windows.yml is a Windows-only copy of that job for fast iteration. It duplicates the job body rather than sharing it — when you change one, change both, or the standalone workflow quietly builds with the previous release's flags.
bazelisk clean --expunge before rebuild only if WORKSPACE patch_cmds changed (otherwise incremental is fine and ~5× faster — a warm cache can finish a platform in 2–5 min, which is normal and not a sign that nothing was built).
Never trust a build's exit code — verify artifacts
Wrapper scripts have reported success while producing nothing (a failed git clone inside the script, an echo after the build masking its status, gh printing a network error and still exiting 0). Confirm each platform by artifact, not by status line:
f=prebuilt/<dir>/libLiteRtLm.dylib # or .so
printf "exists=%s mtime=%s newversion_symbol=%s\n" \
"$([ -f "$f" ] && echo yes || echo NO)" \
"$(stat -f '%Sm' -t '%H:%M' "$f" 2>/dev/null)" \
"$([ -f "$f" ] && nm -gU "$f" 2>/dev/null | grep -c '<symbol-new-in-this-version>')"Print exists next to the symbol count. nm on a missing file emits nothing and grep -c turns that into a perfectly innocent 0 — "feature absent" and "check never ran" must not look identical. The same applies to shell globs: in zsh an unmatched *.so aborts the whole ls, so use find … \( -name '*.dylib' -o -name '*.so' \) in verification loops.
set -o pipefail is not enough when the build is not the last command in the chain: bazel … | tail -20 exits with tail's status, and build_macos.sh reported success through a genuine failure that way. Put the build last, or capture ${PIPESTATUS[0]}.
Fetching artifacts is part of the build — verify those too
Both of our download paths fail silently:
gh run downloadleaves an empty directory rather than erroring when the artifact name doesn't match, and has no--clobber, so a re-download into a populated directory can mix two runs' outputs. Always download into a fresh directory and assert the file count.curlexits0on a truncated transfer. One 2877 MB model arrived as 45 MB with a clean exit. Use--retry 5 --retry-all-errors -C -and verify the byte count againstContent-Lengthbefore using the file.
⚠️ Post-build verification — MANDATORY before pushing prebuilts to repo
Every freshly-built dylib must pass all of these checks. Skipping any one of them has shipped a regression to users.
1. Mach-O architecture (iOS / macOS / desktop)
file packages/flutter_edge_ai_litertlm/native/litert_lm/prebuilt/<dir>/libLiteRtLm.dylibExpected: arm64 for iOS device, arm64 for iOS Sim, arm64 for macOS_arm64. Not x86_64. Upstream 5e0d86b shipped libLiteRt.dylib and libLiteRtTopKMetalSampler.dylib as x86_64 macOS binaries inside prebuilt/ios_arm64/ (#2072) — when you copy them across, double-check.
otool -hv packages/flutter_edge_ai_litertlm/native/litert_lm/prebuilt/<dir>/libLiteRtLm.dylib | tail -2
# → cputype 16777228 (arm64), filetype 6 (DYLIB)2. Header padding (macOS / iOS) — measure it with check #4, not with sizeofcmds
Native Assets re-runs install_name_tool -id @rpath/... on every pub get. Without enough header padding the rewrite fails with:
install_name_tool: changing install names or rpaths can't be redone for: ... larger updated load commands do not fit
This is what bit users in 0.14.1 (#247). Padding cannot be added to an already-linked binary — you must rebuild with -Wl,-headerpad_max_install_names.
Do not test this by comparing sizeofcmds against a threshold. An earlier version of this skill said "need sizeofcmds >= 4096", which is wrong: sizeofcmds is the current size of the load commands, while -headerpad_max_install_names enlarges the free space after them. A small library legitimately has small load commands — libStreamProxy.dylib reports ~1136 and rewrites perfectly. At the v0.15.0 migration that threshold flagged 8 healthy dylibs, including three upstream ones, and would have blocked the release for nothing.
Check #4 below is the only authoritative test — it performs the exact operation Native Assets performs. Run it over every dylib in the directory and trust its result.
For dylibs we don't build ourselves (upstream prebuilts like libGemmaModelConstraintProvider.dylib, libLiteRtMetalAccelerator.dylib, libLiteRtTopKMetalSampler.dylib) that genuinely fail check #4: file an upstream bug, then either rebuild from source with the flag, or relink with optool / ld -r for bigger headerpad.
3. install_name + rpath
otool -D <file> # → @rpath/libLiteRtLm.dylib (NOT absolute path)
otool -l <file> | grep -A2 LC_RPATH | grep " path "
# Expected for macOS: @loader_path/../../..
# Expected for iOS: @loader_path or @executable_path/Frameworks/...4. install_name_tool smoke test
The mandatory test that catches headerpad bugs:
cp packages/flutter_edge_ai_litertlm/native/litert_lm/prebuilt/<dir>/libX.dylib /tmp/test.dylib
chmod +w /tmp/test.dylib
install_name_tool -id @rpath/this_is_a_long_test_path_pad_to_native_assets_target/libX.dylib /tmp/test.dylib
# → must succeed silently. If it fails with "larger updated load commands do not fit", headerpad is too small. STOP and rebuild.Run this for every dylib in prebuilt/<dir>/, not just the one you rebuilt. Native Assets rewrites all of them.
One known, accepted failure: ios_sim_arm64/libLiteRtLmMetalAccelerator.dylib. It is upstream's prebuilt libLiteRtMetalAccelerator.dylib, shipped renamed since native-v0.18.0 (flutter_litert embeds a LiteRtMetalAccelerator.framework of its own, and an app bundle holds one framework per name — see patch_c_api.sh §10). It has only 48 bytes of header slack, so the 91-character test path above does not fit. The name the iOS build actually writes, @rpath/LiteRtLmMetalAccelerator.framework/LiteRtLmMetalAccelerator (66 characters, +32 bytes of load commands), does fit — test that one for this file. The published native-v0.17.1-a carried a file with the same slack, so this is not a regression; it surfaced at the v0.18.0 bump. The device slice (ios_arm64) passes the long path. If a future upstream refresh shrinks the slack further, the framework name stops fitting too, and then it is a blocker: relink or report upstream (see "When upstream is broken").
5. Phase 8 patch markers (iOS / macOS only)
strings packages/flutter_edge_ai_litertlm/native/litert_lm/prebuilt/<dir>/libLiteRtLm.dylib | grep '@executable_path.*LiteRtLmMetalAccelerator'Expected: 1 hit (path to LiteRtLmMetalAccelerator.framework/LiteRtLmMetalAccelerator; before native-v0.18.0 it was the upstream LiteRtMetalAccelerator). If 0 hits, patch_c_api.sh §10b didn't apply — your build was against a tree where WORKSPACE.patch_cmds didn't run. Run bazelisk clean --expunge and rebuild.
strings packages/flutter_edge_ai_litertlm/native/litert_lm/prebuilt/<dir>/libLiteRtLm.dylib | grep -c '^libLiteRtMetalAccelerator.dylib$'Expected: 0. If non-zero, the basename dlopen string is still in the binary — patch failed.
strings packages/flutter_edge_ai_litertlm/native/litert_lm/prebuilt/<dir>/libLiteRtLm.dylib \
| /usr/bin/grep -E 'libLiteRtGpuAccelerator|^(lib)?LiteRtMetalAccelerator(\.dylib)?$'Expected: nothing (since native-v0.18.0-b; build_{ios,macos}.sh fail on any
hit). libLiteRtGpuAccelerator.dylib or a bare LiteRtMetalAccelerator here
means patches/litert_gpu_registry_apple.patch did not apply, and in an app
with flutter_litert the GPU registers that runtime's accelerator and crashes.
Every native-v0.18.0 / -a Apple binary prints libLiteRtGpuAccelerator.dylib.
Strings cannot show the RTLD_DEFAULT fallback being off — the device run in
"Coexisting LiteRT runtimes" above is what does.
6. Required exports
nm -gU packages/flutter_edge_ai_litertlm/native/litert_lm/prebuilt/<dir>/libLiteRtLm.dylib | grep _litert_lm_engine_create
# → must show the symbol as T (text section, exported)Run nm -gU | grep -c '_litert_lm_' — expect ~50 symbols (matches bindings.dart lookupFunction count).
EmbeddingEngine exports (EmbeddingGemma 2). verify_tarball_manifest.sh runs
check_embedding_exports.py on every tarball: libLiteRtLm must export the 33
EmbeddingEngine / ModelInfo / error-reporter functions the .litertlm embedding
pass calls (liteRtLmEmbeddingSymbols in lib/src/embedding/litert_lm_embedding_pass.dart;
a VM test keeps the two lists equal); three are the dispatch-directory setters
the NPU path calls for the main, vision and audio executors. Two are ours, not
upstream's:
litert_lm_embedding_response_get_input_length / _get_truncated_length
(patch_c_api.sh §14, from native-v0.18.0-e) — litertlm 1.13.1 refuses every
EG2 load on a library without them, so a release that drops §14 breaks EG2
text too. They leave through visibility attributes, not the Windows .def, and
no LLM check calls them — a dropped one fails only an EG2 load on that
platform. On a LiteRT-LM pin bump also:
- replace
native/litert_lm/include/c/*.hwith upstream'sc/headers at the new pin, verbatim — then re-add §14's two getter declarations — and regeneratelib/src/ffi/litert_lm_embedding_bindings.dart:dart pub global activate ffigen 20.1.1, then inpackages/flutter_edge_ai_litertlmdart pub global run ffigen --config ffigen_embedding.yaml(ffigen is no dependency of the workspace; upstreammainafter v0.18.0 already changed the embedding setters to return status codes); - re-check R5 §4.8 against
embedding_litert_compiled_model_executor.cc: the pass sends overflow strategy kTruncate on every request and refuses a response whosetruncated_lengthis > 0 (or whoseinput_lengthexceeds 1024) asEmbeddingInputTooLongException(maxInputTokens: truncated_length, actualTokens: input_length). That holds only whiletruncated_lengthis set on the truncate branch alone (to the largest signature, 1024) andinput_lengthis counted before truncation with media tokens included (measured at v0.18.0: text 11509, a 60 s clip 1504, a 954-token text plus one image 1086 — the wheel's kError counts). A NULL response is never an overflow; - on an IMAGE request, status 3 must still mean the image failed to decode
(stb,
stb_image_preprocessor.cc: a corrupt or truncated file) — the pass maps it toEmbeddingEngineException(nativeCode: 3). Re-check too that the bundles' vision signatures are still[70, 140]and that 200/280 still fail (LiteRtEmbeddingBackendaccepts only 140 and 70); - on an AUDIO request, status 2 must still mean miniaudio could not open the
clip (
audio_preprocessor_miniaudio.cc; WAV/MP3/FLAC open, Ogg and M4A do not —liteRtLmAudioFormats), and the audio tower must still be aTFLiteModelsection whosemodel_typestarts withtf_lite_audio_encoder(litertlm_container.dart; ModelInfo claims audio on the 270m and the 440m, which have none).
7. Real pub get smoke test in a fresh project
This catches problems no static check sees. Mandatory before commit:
REPO=/Users/sashadenisov/Work/flutter_edge_ai
cd /tmp
rm -rf test_flutter_edge_ai_native
flutter create test_flutter_edge_ai_native --platforms=macos,ios
cd test_flutter_edge_ai_native
# The repo ROOT is the workspace package (name: flutter_edge_ai_workspace,
# publish_to: none) — pointing --path at it fails with "name field doesn't
# match expected name". And core owns NO native bundle: its hook has
# `const _bundles = <_NativeBundle>[];`. Adding core alone exercises zero
# CodeAssets, so the check that exists to catch the 0.14.1 headerpad bug
# would have passed while testing nothing. Add the package that owns the
# dylibs.
flutter pub add flutter_edge_ai --path="$REPO/packages/flutter_edge_ai"
flutter pub add flutter_edge_ai_litertlm --path="$REPO/packages/flutter_edge_ai_litertlm"
# flutter_edge_ai_embeddings is pure Dart and shares no bundle — never in this list
rm -rf .dart_tool build
flutter pub get
# → must complete without "Failed to set install names" or any other error
flutter build macos --debug # mandatory
flutter build ios --debug --no-codesign # mandatory (catches dylib loading)
# Assert the dylibs actually landed. A green build with zero CodeAssets looks
# identical to a green build with all of them.
find build -name 'libLiteRtLm*' -o -name 'LiteRtLm*' | headThis is the smoke test I skipped in 0.14.1. Doing this once would have caught the libGemmaModelConstraintProvider.dylib headerpad issue before publish — provided it points at the package that carries the bundle.
But note what it still does NOT cover — see the next check.
7b. Build with prebuilt/ moved aside — the only test of what users actually get
_resolveLibDir tries local packages/flutter_edge_ai_litertlm/native/litert_lm/prebuilt/<dir>/, then the cache; its caller falls back to _downloadAndExtract from native-v<version>. The cache path is NOT version-scoped — staleness is decided by the marker file. End users only ever reach the download — prebuilt/ is gitignored and ships in no pub package (confirm with dart pub publish --dry-run | grep -ciE 'prebuilt|\.so$|\.dylib|\.dll' → must be 0).
On a maintainer machine the first source always hits. So every build you run locally — including check #7 and the release skill's flutter build apk --release pre-flight — silently validates your bundle and never touches the download, checksum verification, or extraction. Green means nothing about the artifact you are about to publish.
After the release exists and the hook's checksums are updated, force the real path:
mv packages/flutter_edge_ai_litertlm/native/litert_lm/prebuilt /tmp/prebuilt-aside
# Cache location is per-OS (_cacheBaseDir()). On the Windows NPU VM the macOS
# path wipes nothing and you silently re-test the cache instead of the download.
# macOS ~/Library/Caches/flutter_gemma/native
# Linux ~/.cache/flutter_gemma/native
# Windows %LOCALAPPDATA%\flutter_gemma\native
rm -rf "$HOME/Library/Caches/flutter_gemma/native" # kill the cache too, or you test source #2
cd packages/flutter_edge_ai/example && flutter clean && flutter pub get && flutter build apk --release
mv /tmp/prebuilt-aside packages/flutter_edge_ai_litertlm/native/litert_lm/prebuiltPack the tarballs from the exact directory you device-tested, so the published bytes match the verified bytes by construction rather than by coincidence.
This is the same failure shape as the frozen NPU dispatches: local state standing in for what goes out the door.
8. (App Store-bound builds) Frameworks/ structural check
After flutter build ipa --release or archive:
R=build/ios/iphoneos/Runner.app
ls "$R/Frameworks" | wc -l # must be NON-ZERO
ls "$R/Frameworks" | grep -E 'LiteRtLm|LiteRtLmMetalAccelerator|GemmaModelConstraintProvider|StreamProxy'
find "$R/Frameworks" -maxdepth 1 -type l # must be empty
find "$R/Frameworks" -maxdepth 1 -name "*.dylib" -type f # must be emptyIf anything other than .framework/ is in there, App Store will reject with ITMS-90432 ("Unexpected file found in Frameworks").
Count the contents first — an empty Frameworks/ passes every "must be empty" test. flutter build ios can leave a half-assembled Runner.app behind: the Xcode phase reports Xcode build done, then flutter_tools dies cleaning up its own temp dir (PathNotFoundException: Deletion failed … flutter_ios_build_temp_dir…), and the frameworks are never embedded. The bundle exists, the two negative checks come back perfectly clean, and nothing distinguishes "compliant" from "nothing was built". Re-running the build fixes it; a healthy debug build of the example carries ~10 frameworks, four of them ours. Same failure shape as nm on a missing file — assert the positive before trusting the negative.
9. A tool call, on every platform you shipped
The litertlm smoke suite never passes a tool to a session, so nothing in checks
1–8 loads libGemmaModelConstraintProvider. That library is a Google prebuilt
you did not build, against a Constraint interface that lives in the runtime
you did — and when the two disagree, the first tool call segfaults in
CompositeLogitMask::Apply, with no Dart-side error and every other check
green. native-v0.17.0 shipped exactly that.
cd packages/flutter_edge_ai/example
flutter test integration_test/litertlm_native_tools_test.dart -d <device>Six cases: FunctionGemma and Gemma 4, a manual round and the SDK loop, plus a session switch. Each asserts the text after the tool result, not merely that a call was parsed — a suite that stops at "a call was detected" stayed green for months while the model was answering its own tool result by calling again.
Stage the four models first (functiongemma-270M-it, mobile_actions_q8_ekv1024,
tiny_garden, gemma-4-E2B-it) in the app documents dir, or in
/data/local/tmp/flutter_gemma_test/ on Android.
The static pre-check, before any device is involved — the provider must carry the type the runtime asks it for:
H=/tmp/LiteRT-LM/runtime/components/constrained_decoding/constraint.h
P=<prebuilt>/libGemmaModelConstraintProvider.dylib # or .so / .dll — ONE file
if [ ! -f "$H" ] || [ ! -s "$P" ]; then
echo "cannot check: need both $H and a non-empty $P"
elif [ "$(grep -q ComputeMask "$H" && echo 1 || echo 0)" \
= "$(grep -q LogitMask "$P" && echo 1 || echo 0)" ]; then
echo "provider ABI: source and provider agree"
else
echo "MISMATCH — every tool call on that platform would segfault"
fiThree things this shape is deliberate about: grep reads the binary directly
(strings … | grep -q exits at the first match and, under set -o pipefail,
the SIGPIPE makes the whole pipeline report failure — the guard then says "no
LogitMask" for every provider that has one); it names ONE file rather than a
glob, because a glob over a directory holding both a .so and a .dylib
answers for the union; and it prints rather than exits, because you paste it
into your own shell.
Two-sided, and -f first, for the same reason the CI guard is: a provider
OLDER than the runtime segfaults, a NEWER one segfaults the same way from the
other side (which is what happens if you build an older ref while PREBUILT_REF
still points at main), and a grep on a missing header silently answers "no".
Linux has a second provider ABI on top of this one (#551). The Linux
providers are built with Google's libc++ (std::__u), our runtime with clang +
libstdc++, so patch_c_api.sh section 12 puts gemma_constraint_abi_bridge.h
between them: it calls the provider's vtable slots with the provider's own
convention and reads its BitmapLogitMask at fixed offsets. Those facts were
measured on the 4453b286 binaries. Whenever PREBUILT_REF moves,
disassemble the new Linux providers (FstConstraint::Start, ComputeNext,
ComputeMask, the BitmapLogitMask constructor) and re-check the constants at
the top of the bridge; the gate is litertlm_native_tools_test passing on
Linux x86_64 AND arm64 — the old cast died there on the first constrained token
while every other suite stayed green.
9b. 16 KB page alignment (Android) — Google finds this, not you
Google Play rejects an app when any .so in its APK has a PT_LOAD
p_align below 16 KB: "Your app does not support 16 KB memory page sizes".
Play scans lib/**/*.so and does not care that a Hexagon blob is parsed by the
DSP rather than mapped by the kernel.
This is not hypothetical and it is not caught by anything else. native-v0.17.0-a
shipped four Qualcomm Skel blobs at p_align=0x1000 — straight from the QAIRT
SDK, unchanged by us — and because androidExtraLibs puts them in every
consumer APK, every app shipping flutter_edge_ai_litertlm was rejected. The
build was green, every test passed, the manifest gate passed, and the report
came from a downstream app's store submission (#529).
build_qualcomm_dispatch.sh now raises p_align on the staged blobs (and
refuses when p_vaddr and p_offset are not congruent mod 16 KB, because a
bump there would produce a binary that does not load). verify_tarball_manifest.sh
asserts it for every ELF object inside every tarball. Read its output:
[ok] litertlm-android_arm64.tar.gz — 19 ELF object(s), all 16 KB-alignedA run that reports 0 objects inspected has found nothing, not proven anything.
Google's own check_elf_alignment.sh is the second opinion if you want one.
9c. Every Android import is reachable through its own NEEDED
Bionic resolves a library's undefined symbols only through that library's own
DT_NEEDED chain — never through whatever else is already loaded in the
process, so preloading from the app cannot fix it. A GLOBAL import that misses
fails dlopen, which at least is loud. A WEAK one fails nothing: it binds to
NULL, dlopen succeeds, and the process jumps to address 0 the first time a
code path calls it — which may be one GPU vendor's path only.
That is #545. Upstream v0.17.0's libLiteRtOpenClAccelerator.so and
libLiteRtGpuAccelerator.so import AHardwareBuffer_allocate/_release weakly
with no libandroid.so in NEEDED. Adreno never calls them; Mali does, and
engine_create SIGSEGVs at pc 0. We shipped those bytes unchanged in three
releases, because the only Android GPU run was on an Adreno phone. #270 was the
same rule broken by a sampler (GLOBAL LiteRtCreateEnvironment without
libLiteRtLm.so), fixed then as a one-off — which is why it came back.
check_android_needed.py asserts the rule for every aarch64 library: each
import that some NDK platform stub or bundle library provides must have a
provider in the importer's NEEDED closure. build_android.sh runs it as step
8d, verify_tarball_manifest.sh runs it on the packed tarball. The fix, when it
fails, is patchelf --add-needed on the importer (steps 8b/8c), never an
allowlist. patchelf missing is a build error, not a warning.
It also asserts that every NEEDED entry is loadable at all — in the bundle, an
NDK platform library, or a vendor library the core manifest requests with
<uses-native-library> (VENDOR in the script; keep the two in step). The
symbol rule alone cannot see a NEEDED library that is simply absent: its
exports are unknown, so every symbol it would provide reads as "no known
provider" and is skipped. That is pitfall #18.
[ok] 12 aarch64 libraries checked against API 35 stubs12, not 15: since native-v0.18.0 the Android bundle no longer carries the three
Dawn-dependent companions (libLiteRtGpuAccelerator.so,
libLiteRtWebGpuAccelerator.so, libLiteRtTopKWebGpuSampler.so). Android GPU
is OpenCL only — the same as Google's litertlm-android AAR (one
liblitertlm_jni.so with ML Drift OpenCL linked in) and litert-gpu (only
libLiteRtClGlAccelerator.so). Do not "fix" the bundle by adding
libwebgpu_dawn.so: that puts libLiteRtGpuAccelerator first in
gpu_registry's Android order, a path no shipped SDK uses.
10. NPU on real silicon — the only check that covers the dispatch libraries
Nothing in checks 1–8 touches NPU. Both dispatch libraries load only when PreferredBackend.npu is requested on matching hardware, so they need real devices:
- Intel NPU — LunarLake/PantherLake. Our access is Intel's Lunar Lake instance
litertlmtest(Core Ultra 7 258V),ssh lnlthrough Intel's SSH bastion (host entries and lease date in theproject_intel_npu_vmmemory; an expired lease wipes the disk).cmd.exeshell. Timings come only from console session 1 (PsExec64 -i 1 -d, then poll a log file): the ssh session is session 0, where GUI apps do not draw and runs are slower. The test model is not on HF — it lives on the box at%USERPROFILE%\dev-gemma4-2b-lnl\gemma4_2b_lnl.litertlm. Put the bundle under test in the checkout'sprebuilt/windows_x86_64/(the hook reads it before the cache), then run the litertlm smoke suite with--plain-name "NPU"on its own: inside the full file the NPU group runs after GPU, and async WebGPU teardown blows the per-test timer on the first NPUengine_create. Reference numbers (0.15.2):engine_create~1 s, ~54 chunks/s, greedy run1 == run2. - Qualcomm NPU — Snapdragon 8 Gen 3 / 8 Elite. Ours is Qualcomm Device Cloud (QDC); the exact sequence is below.
The NPU group skips (and counts as passed) when its model file is absent, so a green run means nothing until the log shows engine_create on the NPU. Grep for it; a [Gemma4 NPU] SKIP line means the check did not run.
Record the outcome here, every release — device, date, pass count. The method was written down long before any result was, and a finished check kept reading as pending:
| Native | Qualcomm (QDC) | Intel (Lunar Lake box) |
| v0.15.2 | — | ✅ 18/18, Lunar Lake 258V, 2026-05-15 |
| v0.16.0 | ✅ 23/23, Snapdragon 8 Elite, 2026-08-15 | ⚠️ not recorded (Intel's demo app on native-v0.16.0: NPU works, ~61 tok/s, 2026-10-16) |
| v0.17.0 | ⏳ pending | ❌ engine_create: Failed to load model from fd — regression #20, Lunar Lake 258V, 2026-10-16 |
| v0.18.0 | ✅ 24/24, Snapdragon 8 Elite Gen 5 (SM8850, HTP V81, QDC), 2026-10-08 — AAB split install, QNN 2.50.0 from Maven via qualcomm_npu, engine_create 429 ms, greedy run1 == run2 | ❌ native-v0.18.0-e: 0xC0000409 at the first NPU engine_create (regression #20). ✅ with §15, CI run 38917181364: NPU 3 passed + 2 skipped (vision, audio), rest 22/22, Lunar Lake 258V, 2026-10-16 — engine_create 0.9–1.1 s, 58.4 tok/s, greedy run1 == run2 |
| v0.18.0-f | ✅ Android: ffi NPU group and NPU media (towers on CPU) pass, 5 NPU engine_creates, Galaxy S25 Ultra (SM8750, HTP V79, DDP pa3q-35), 2026-10-18. Linux QCS8275 not re-run: its dispatch and shim are byte-identical to -e | ✅ NPU group +3 ~2 (vision, audio: no Windows towers bundle), full file +25 ~2, Lunar Lake 258V, 2026-10-18 — engine_create 0.8–1.1 s, ~61 chunks/s, greedy run1 == run2, no 0xC0000409 (CI DLLs byte-identical in the app) |
| v0.18.0-g | — (Android archive byte-identical to -f) | ✅ Windows archive alone changed (OpenVINO 2026.4.1, openvino_intel_npu_vm_runtime.dll), CI run 39573062034: full file +25 ~2, NPU group +3 ~2, Lunar Lake 258V, 2026-10-21 — engine_create 0.9–1.0 s, 59.6 tok/s, greedy run1 == run2, all 25 archive DLLs byte-identical in the app. Panther Lake (probe app, one run each): E2B NPU 53.6 tok/s, Intel's Gemma 4 12B NPU 13.0 tok/s, EmbeddingGemma 2 270m _Intel_PTL on the NPU; Intel NPU driver 32.0.100.4778 on both. tok/s figures are single runs |
| v0.18.0-h | — (Android archive byte-identical to -g) | ✅ Windows archive alone changed (both libLiteRtWebGpuAccelerator.dll from LiteRT-LM 5f973f57, LiteRT #11120, #4083; NPU stack byte-identical to -g): litertlm_ffi_test NPU _intel_PTL engine_create, greedy run1 == run2, 59.7 tok/s, first token 116 ms; EmbeddingGemma 2 270m _Intel_PTL on the NPU equal to -g, Panther Lake, 2026-10-28. EG2 on the GPU crashes with this accelerator (webgpu_dawn.dll, #744), refused on Windows since litertlm 1.19.0 |
Google Tensor (since native-v0.18.0-f, behind google_tensor_npu): ✅ Pixel 11 (Tensor G6, Android 17 CD1A.260714.001.A9, DDP cubs-37), debug build, 2026-10-18 — Gemma 4 E2B G6 on the NPU (gpu refused before any engine, greedy x2 equal), npu_all_towers_test with NPU_STRICT_ONLY=true (text, image and audio on the NPU), EmbeddingGemma 2 270m and 440m G6 on the NPU; the dispatch dir held only libLiteRtDispatch_GoogleTensor.so with both dispatches in the APK. Not run: a Tensor G5, a release-signed build. Google's Tensor SDK terms forbid publishing Tensor benchmarks, so record no Tensor speeds here.
Both are slow, awkward, and easy to skip. Skipping them is what shipped the two frozen dispatch libraries described above.
QDC and Android instrumentation, in the order that works
QDC gives you an SSH tunnel that forwards the ADB server port (5037), not a device port. Consequences:
adb kill-serverfirst, then open the tunnel. A local ADB server already holding127.0.0.1:5037makes ssh bind IPv6 only, and the symptom is a device list that is simply empty.nohup … & disown— there is nosetsidon macOS, and with it the tunnel never starts at all.flutter test -d <device>cannot work here. It needsadb forwardfor the Dart VM Service, and with the server itself proxied the forward binds on the remote side. Use an Espresso instrumentation run instead.- Sessions are time-capped (100 min standard, 300 on request). A tunnel that stops responding mid-run is usually just an expired session — check that before debugging the transport.
The tunnel itself (key file and session host come from the QDC session page; the host changes per session — it was sa762844, then sa763293). The run on 2026-08-14 wrapped it in a small loop that re-spawns ssh when it drops:
adb kill-server
nohup ssh -i ~/Downloads/qdc_id_<date>.pem -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30 \
-L 5037:<session-host>.sa.svc.cluster.local:5037 -N sshtunnel@ssh.qdc.qualcomm.com > tunnel.log 2>&1 & disown
adb devices -l # must list the QDC device; check ro.soc.model before anything elseThe NPU model is SoC-specific and is not on the device: gemma-4-E2B-it_qualcomm_sm8750.litertlm (sm8750 = Snapdragon 8 Elite, QNN HTP V79, ~2.9 GB) from litert-community/gemma-4-E2B-it-litert-lm. engine_create rejects it on any other SoC. The QDC device has internet, so download it on the device — pushing 3 GB through the tunnel eats the session and drops (keep adb push with retries only as the fallback):
adb shell 'mkdir -p /data/local/tmp/flutter_gemma_test && cd /data/local/tmp/flutter_gemma_test && \
curl -sL -o gemma-4-E2B-it_qualcomm_sm8750.litertlm \
https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it_qualcomm_sm8750.litertlm'A bundle that is not on HF has to go through the tunnel, at ~2–3 MB/s. A single adb push of 2.6 GB died mid-way on 2026-10-08 when the tunnel dropped (and adb deletes the partial file). What worked: re-spawn the ssh in a while true loop, adb kill-server before each respawn's first use (a dropped tunnel lets the next adb call start a LOCAL server on 5037, and ssh then cannot bind), split the file into 128 MB chunks, push each with retries until the device holds it at full size, cat them together on the device and compare sha256sum with the local file. The SM8850 (8 Elite Gen 5, V81) Gemma 4 bundle is not on HF; the test looks for it as gemma-4-E2B-it_qualcomm_sm8850.litertlm. The CPU/GPU gemma-4-E2B-it.litertlm downloads on the device itself in ~2 minutes.
Check first, before spending minutes: getprop ro.soc.model must say SM8750 (platform sun) or SM8850 (platform canoe) — whichever SoC the staged bundle was compiled for. /vendor/dsp/cdsp/ carries no libQnnHtpV79Skel.so even on Qualcomm's own reference image — that is why the bundle ships the Skel libraries itself.
Build both APKs with the test as the target, install both, and run the instrumentation (the app id and runner are in example/android/app/build.gradle.kts):
T=<absolute path to integration_test/litertlm_ffi_test.dart>
./gradlew app:assembleDebug -Ptarget="$T" app:assembleDebugAndroidTest -Ptarget="$T"
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
adb shell am instrument -w -r dev.flutterberlin.flutter_gemma_example.test/androidx.test.runner.AndroidJUnitRunnerOK (N tests) is the pass line. On v0.16.0 the first full pass was Tests run: 23, Failures: 2, and the rerun went OK (23 tests) — read the failures before rerunning.
Building the instrumentation APK: flutter build apk is wrong, it packs main.dart. The app APK must carry the test entrypoint:
cd packages/flutter_edge_ai/example/android
./gradlew app:assembleDebug -Ptarget=<absolute path to integration_test/xxx_test.dart>Verify the entrypoint actually landed with binary grep — strings misses it in the compiled kernel blob:
grep -ac "<a string unique to the test file>" app/build/outputs/apk/debug/app-debug.apkAnd beware the Espresso idle timeout: Could not launch intent within 45000 ms is usually not a broken test but app startup doing real work — ours was restoring a 3 GB active model, so the main thread never went idle. Clear app state, or start from a build that doesn't auto-restore.
10. Functional matrix — every platform, every native consumer
The bundle has three consumers — LLM inference (LiteRT-LM C API), embeddings and speech (LiteRT C API through hand-mirrored structs) — and a platform can break one while the others pass. Windows embeddings and speech were broken from native-v0.16.0 until 2026-09-18 while every Windows GPU and NPU run was green, because nobody ran them there (pitfall #16). Run all three everywhere:
| Suite | What it covers |
litertlm_ffi_test.dart (full) | CPU + GPU text/streaming/vision/audio/thinking, chat, multi-session (several engine_create per process), embeddings (Embedding non-blocking (#299)) |
litertlm_stop_rewind_test.dart | patch_c_api.sh §16: a stopped turn is rewound, not rebuilt — images, audio, thinking, stop by stopGeneration() and by a cancelled subscription. Once per backend (--dart-define=REWIND_BACKEND=gpu); every row must log rewound, never rebuilt. The Dart side falls back to a quiet text rebuild on a library without the export, so this is the check that §15 reached the platform; skip it only on an NPU-only device |
stt_moonshine_test.dart | STT through the LiteRT C API (~104 MB model from HF) |
tts_smoke_test.dart | Matcha TTS; compares against a golden (rms 0.0781, error < 1e-5 on every platform in v0.17.0) |
qwen3_tts_test.dart | only when Qwen3 code changed (~1 GB download); macOS is enough |
Where and how, with the traps that cost time in the v0.17.0 cycle:
- macOS — local. The example's Podfile stager copies companions from the cache (
~/Library/Caches/flutter_gemma/native/macos_arm64/), not from the build, so a maintainer machine runs a 0.16/0.17 mix. For the run, move the cache'slibGemmaModelConstraintProvider.dylibaside (the stager picks its source by that one file and falls back toprebuilt/), restore it in atrap. - iOS device — USB only (
ioreg -p IOUSBmust showiPhone@…;transportType: localNetwork⇒ "Cannot start app on wirelessly tethered iOS device"). On Xcode 26+enable-lldb-debuggingmust betrue, otherwise flutter never sees the VM Service URL and hangs at "not discovered" (memoryproject_ios_device_xcode26_lldb). The simulator is CPU-only by design. - Android — GPU/OpenCL needs real devices, and at least one Adreno and one Mali: Adreno and Mali take different paths through the same accelerator (#545 crashed only on Mali). On DDP (the
ddp-device-testskill, not Firebase Test Lab):e1q-34(Galaxy S24, Adreno 750),akita-35(Pixel 8a, Mali-G715),a34x-36(Galaxy A34, Mali-G68 — the #545 device). Write the DDP id + GPU per row in the PR body; "Galaxy S24" alone does not say which GPU ran. Thegemma_api36_arm64emulator is enough for CPU speech. - Linux — GCE
flutter-gemma-linux(T4) andflutter-gemma-linux-gpu(L4, often stocked out inus-central1-b). The T4 box boots a mainline 6.16 kernel without the NVIDIA module (Vulkan falls back tollvmpipe); for GPU runskexeconce into6.8.0-*-gcp. Prove the GPU was used from throughput (T4: ~42 chunks/s GPU vs ~2.4 CPU), not from test names. - Windows — the Intel Lunar Lake box (
ssh lnl) for NPU, Arc, CPU, embeddings and speech; GCEflutter-gemma-gpu(T4) only for discrete-GPU WebGPU. On GCE: Scheduled Task (S4U) + log polling,powershell -EncodedCommandfor anything with$.
Put a worktree of the branch on each VM instead of switching its checkout, drop the CI artifact into prebuilt/<platform>/, and prove provenance per run: sha256 (Linux/Windows) or LC_UUID (Apple) of the libraries inside the built app against the artifact. Record the matrix in the PR body.
Common pitfalls (regressions we already shipped)
| # | Bug | Caught by | Hit users in |
| 1 | iOS dylib built from v0.10.2 (no Metal accelerator), EXC_BAD_ACCESS | Check #5 (patch markers absent) + iPhone smoke | dev session, never shipped |
| 2 | App Store ITMS-90432 — lib*.dylib symlinks in Frameworks/ | Check #8 | 0.14.0 |
| 3 | dart:ffi in web build — missing conditional import | flutter build web --release | 0.14.0 |
| 4 | libGemmaModelConstraintProvider.dylib headerpad too small | Check #2 + #4 + #7 | 0.14.1 |
| 5 | lipo "same architectures (arm64)" — Native Assets called twice for iOS | hook/build.dart _prebuiltDirName returns null for iOS x64 | dev session |
| 6 | upstream libLiteRt.dylib was x86_64 macOS binary in prebuilt/ios_arm64/ (#2072) | Check #1 (file) | 5e0d86b commit (upstream bug, but we should detect on copy) |
| 7 | NPU dispatch stacks dropped from the tarballs — packed without them | Count per bundle before packing | caught pre-publish at v0.14.0 and v0.15.0 |
| 8 | LiteRT pin moved, LiteRtCreateModelFromFile 2→3 args → embeddings status=500 | Header diff at both LITERT_REFs | v0.14.0 dev cycle, ~1 day lost |
| 9 | RTLD_DEFAULT undeclared on glibc — needs _GNU_SOURCE before every include | CI Linux job (macOS compiles it happily) | caught in CI at v0.15.0 |
| 10 | v0.15.0 changed LiteRtLmStreamCallback 4-arg → 2-arg, no compat overload | ACCESS_VIOLATION (Windows) / malformed UTF-8 (macOS) at first token | caught pre-publish at v0.15.0 |
| 11 | --define=litert_link_capi_so=true deleted upstream — inert, statically linked runtime → Windows GPU engine_create crash | Grep the define in the tree being built | v0.14.0 → v0.16.0, misfiled upstream as #2957 |
| 12 | Intel NPU dispatch carried forward at OpenVino 2026.2.0 against a pin requiring 2026.3.0 | Build the dispatch from the pin; CI asserts the staged openvino.dll matches the SDK's version.txt | v0.11.0-b → v0.16.0, misfiled upstream as #3217 |
| 13 | Qualcomm dispatch hardcoded to LiteRT 5c5b9ce6 — SIGSEGV in LiteRtDestroyOptions, and -gcc-toolchain when rebuilt | Derive LITERT_REF from WORKSPACE; check #9 on device | native-v0.12.0 → v0.16.0; rebuilt and verified on Snapdragon 8 Elite via QDC at v0.16.0 |
| 14 | Blanket copy of OpenVino runtime\bin ships debug DLLs (openvinod.dll) beside release | Explicit allow-list + throw on missing | caught pre-publish at v0.16.0 |
| 15 | QNN runtime libs left at an old QAIRT while the dispatch moved — Qnn System library version 1.8.0 is mismatched | Check #9 on device; compare file sizes against the SDK | native-v0.12.0 → v0.16.0, caught on device |
| 16 | Windows-only LiteRtLayoutMsvc mirror kept after LiteRT d84656955 unified the layout — every host-memory tensor buffer got a shape 4 bytes off: CreateTensorBufferFromHostMemory status=3 in embeddings and STT | Check #10 on Windows; struct diff at both LITERT_REFs | native-v0.16.0 → fixed in 67b9857f; seen on Tiber 2026-08-10 and misread as a bad box cache |
| 17 | Upstream companion imports a symbol its NEEDED cannot reach: sampler without libLiteRtLm.so (#270, silent CPU fallback); v0.17.0 accelerators without libandroid.so (#545, SIGSEGV on Mali only) | Check #9c (check_android_needed.py); Android GPU on Adreno and Mali | #270: 0.14.x; #545: native-v0.17.0 → v0.17.1, upstream #3575 open six days before our first release |
| 18 | Three Android companions NEED libwebgpu_dawn.so, which the bundle never carried — dlopen fails, gpu_registry silently moves on to OpenCL, ~18 MB of unloadable code in every APK | Check #9c (now also asserts every NEEDED is loadable); compare against what Google's own AARs ship | native-v0.14.0 → v0.17.x, found by a Codex review at the v0.18.0 bump; fixed by dropping the three, not by adding Dawn |
| 19 | Apple gpu_registry tried the generic libLiteRtGpuAccelerator first and, on the failed dlopen, dlsym(RTLD_DEFAULT) bound flutter_litert's accelerator (another LiteRT) — crash in LiteRtCreateCompiledModel | Check #5 (generic names absent) + the app with flutter_litert on a real iPhone | litertlm 1.10.0 / native-v0.18.0-a, reported from an app on the day of release; fixed in native-v0.18.0-b |
| 20 | LiteRT-LM 9589b6b4 loads an AOT NPU bundle's sections through litert::Model::CreateFromFd; on Windows the fd is a C runtime descriptor and libLiteRt.dll links its own CRT — Failed to load model from fd (v0.17.0), 0xC0000409 in libLiteRt.dll (v0.18.0-e) | Check #10 on the Intel NPU — the only check that loads an AOT bundle | native-v0.17.0 → v0.18.0-e, upstream #4023; fixed by patch_c_api.sh §15. No Intel result was recorded after v0.15.2 |
Every one of those would have been caught by checks 1-10 before commit. Run them all every time.
Three patterns deserve emphasis because nothing in the build output hints at them:
- #7, #12 and #13 are silent. The tarball packs, the checksums verify, every smoke test on CPU/GPU passes. Only a user on
PreferredBackend.npufinds out. - #8 and #10 are ABI drift, not code changes on our side. C linkage means a stale call shape still links; you get register garbage instead of a compiler error. When a bump touches any callback or C-API signature, verify the shape at both refs rather than assuming source that compiles is source that's correct.
- #11, #12 and #13 are the same organizational defect wearing three costumes. Every one is an input we pinned once and stopped maintaining — a define, an SDK version, a commit SHA — that only misbehaves when upstream touches the code depending on it. Each surfaced at a version boundary and each was reported upstream before the real cause was found. Before filing an upstream regression, list every input to the comparison and confirm which ones moved. If one of them is ours and frozen, it is ours until proven otherwise.
When upstream is broken
We import some dylibs as-is from upstream LiteRT-LM (libGemmaModelConstraintProvider.dylib, libLiteRtMetalAccelerator.dylib, sampler dylibs). When upstream ships them with insufficient headerpad / wrong arch / broken exports:
- Before adopting an upstream tag, read what is already reported against it.
gh issue list --repo google-ai-edge/LiteRT-LM --search "created:>=<tag-date>"and skim for crashes on platforms we ship. #545 was upstream #3575 — same crash, same BuildId, samepatchelfworkaround — open six days before our first release with those bytes. - Rule out our own frozen inputs first. Before writing the issue, enumerate every input to the failing comparison — defines, SDK versions, pinned SHAs, carried-forward binaries — and confirm which ones actually moved between the working and broken versions. Two of our four most recent upstream reports (#2957, #3217) were our configuration and had to be retracted; in both the "regression" was upstream finally exercising something we had frozen years earlier. A bisection that lands on a version boundary tells you when, never whose.
- File an issue with reproducer — see
project_litertlm_upstream_*memories for our open ones (#1990, #2072, #2073, #2080). - Don't ship them blindly. Run check #4 (install_name_tool smoke) on every upstream-sourced dylib before copying to
prebuilt/. If it fails, don't publish — find a workaround first (relink, or ship without that lib + change Dart code path). - Add a memory entry documenting the bug + workaround in
~/.claude/projects/.../memory/project_*.mdso future sessions don't re-discover it.
After successful build
- Run the verification checklist 1-10 above. All checks must pass.
- Do NOT try to commit the dylibs —
prebuilt/is gitignored (.gitignore**/packages/flutter_edge_ai_litertlm/native/litert_lm/prebuilt/) andgit ls-filesreturns nothing under it. The bundles reach users through the GitHub Release only; the working copy is yours alone. (This step used to saygit add prebuilt/<dir>/*.dylib, which cannot succeed.) - Pack tarballs + update
hook/build.dartchecksums+ re-upload to GitHub Releasenative-v<version>(seereleaseskill). - Run
dart pub publish --dry-run— must show 0 warnings. - Only then publish.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

