From a175632e091b21afcb529f21ba853dbcc07293e4 Mon Sep 17 00:00:00 2001 From: Mateusz Sluszniak <56299341+msluszniak@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:37:10 +0200 Subject: [PATCH 1/6] fix(libs): stop shipping backends the app opted out of (#1466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the parts of #1464 that hold up under checking. The opt-out gated downloads and linking; it did not control what the `core-*` artifacts contain, and on Android it did not control what ships. Artifacts republished as [`v0.10.1-libs`](https://github.com/software-mansion/react-native-executorch/releases/tag/v0.10.1-libs). **Repackaging only: every library byte is identical to v0.10.0-libs, nothing was recompiled**, so this cannot introduce a behaviour change or hit the Vulkan build's non-determinism. | artifact | v0.10.0-libs | v0.10.1-libs | |---|---|---| | core-android-arm64-v8a | 7.96 MB | 4.49 MB | | core-android-x86_64 | 8.50 MB | 4.50 MB | | core-ios | 20.82 MB | 5.87 MB | - **`package-release-artifacts.sh`** stages the runtime only into core. MPS goes with it (§9): `libbackend_mps_*.a` had no toggle in `ALL_BACKENDS` and the podspec never referenced it. - **`build.gradle.kts`** drives `packaging.jniLibs.excludes` from the resolved config (§4, §5), the Android counterpart of the podspec choosing its `-force_load` entries. - **`download-libs.js`** prunes backends absent from the config after extracting (§3, §6), so turning one off after an install takes effect instead of silently shipping the old binary. - **`COPYFILE_DISABLE=1`** when packing (§12). **§4 was inferred in the issue; it is now observed.** apps/legacy/bare-rn, release, arm64-v8a, `backends: ["vulkan"]`, old core staged so XNNPACK is present on disk: | build | merged_native_libs | libxnnpack | |---|---|---| | without the gradle fix | 269,127,104 B | present, 2,673,072 B | | with `jniLibs.excludes` | 266,454,032 B | absent | Delta is exactly the XNNPACK library. Read from `merged_native_libs/release/.../arm64-v8a`, the tree the APK packages. **iOS**: a Release link of the same app against the reduced `core-ios` succeeds. Everything the linker takes from `third-party/ios`: ``` libs/executorch/libthreadpool_ios.a XnnpackBackend.xcframework/ios-arm64/libXnnpackBackend.a (-force_load) CoreMLBackend.xcframework/ios-arm64/libCoreMLBackend.a (-force_load) MLXBackend.xcframework/ios-arm64/libMLXBackend.a (-force_load) + ExecutorchLib.framework ``` None of the 21 removed archives appears, so §5 is confirmed by the linker rather than by reading the podspec. **§6**: turning Vulkan on, then off, now removes the stale binary instead of leaving it to ship. **§12: there are no AppleDouble sidecars in v0.10.0-libs.** `tar tzf | grep -c '\._'` is 0 for all eight tarballs checked, against the 7 the issue reports. `COPYFILE_DISABLE=1` is added anyway, since it costs nothing and the failure mode is real on macOS. §7 root cause (the two Vulkan binaries differ; fixing §1 removes the *consequence*, since the standalone build is now the only one that can ship, but not the cause), §8 and §11 (inferred from `-force_load` and section sizes, no archive build run), §10 simulator split, §13 docs. - [ ] Yes - [x] No - [x] Bug fix (non-breaking change which fixes an issue) - [x] iOS - [x] Android `nativeLibsVersion` is bumped to 0.10.1, so a fresh install pulls the new artifacts. Declare `backends: ["vulkan"]` in an app's package.json, build a release APK, and check `unzip -l app-release.apk | grep 'lib/arm64-v8a/.*\.so'`: `libxnnpack_executorch_backend.so` should be absent. Flip a backend off after an install and confirm the postinstall log reports removing it. Refs #1464. --------- Co-authored-by: Mateusz Słuszniak --- .../__tests__/api/modelVariants.test.ts | 88 ++++++++++++++ .../__tests__/legacy/installGuard.test.ts | 108 ++++++++++++++++++ .../android/build.gradle.kts | 28 ++++- packages/react-native-executorch/package.json | 2 +- .../scripts/download-libs.js | 89 ++++++++++++++- .../scripts/package-release-artifacts.sh | 49 ++++++-- 6 files changed, 344 insertions(+), 20 deletions(-) create mode 100644 packages/react-native-executorch/__tests__/legacy/installGuard.test.ts diff --git a/packages/react-native-executorch/__tests__/api/modelVariants.test.ts b/packages/react-native-executorch/__tests__/api/modelVariants.test.ts index 93b1e229a0..8c82b22af3 100644 --- a/packages/react-native-executorch/__tests__/api/modelVariants.test.ts +++ b/packages/react-native-executorch/__tests__/api/modelVariants.test.ts @@ -369,6 +369,46 @@ describe('feature map', () => { return found; } + /** The Hugging Face repo slugs a registry category's URLs point at. */ + function repoSlugs(node: unknown): Set { + const found = new Set(); + const walk = (value: unknown): void => { + if (typeof value === 'string') { + const match = value.match(/react-native-executorch-([^/]+)\//); + if (match) found.add(match[1]!); + } else if (Array.isArray(value)) value.forEach(walk); + else if (isObject(value)) Object.values(value).forEach(walk); + }; + walk(node); + return found; + } + + // `features` provisions for the whole package, and the legacy API keeps model + // URLs the new registry has since dropped: the RF-DETR keypoint MLX export + // removed in #1418 is still reachable as + // RF_DETR_KEYPOINT_PREVIEW_MLX_FP32_MODEL. Those count as published, matched + // to a feature through the repo slug they share with the new registry; a + // legacy-only slug cannot be classified that way and is skipped. + const legacyBackendsBySlug = (() => { + const source = readFileSync(join(__dirname, '../../legacy/src/constants/modelUrls.ts'), 'utf8'); + const bySlug = new Map>(); + for (const [, slug, backend] of source.matchAll( + /\$\{URL_PREFIX\}-([^/`]+)\/[^`]*?\/(xnnpack|coreml|mlx|vulkan)\//g + )) { + if (!bySlug.has(slug!)) bySlug.set(slug!, new Set()); + bySlug.get(slug!)!.add(backend!); + } + return bySlug; + })(); + + /** New-registry exports for a category, plus the legacy ones for its repos. */ + function allPublishedBackends(node: unknown): Set { + const found = publishedBackends(node); + for (const slug of repoSlugs(node)) + for (const backend of legacyBackendsBySlug.get(slug) ?? []) found.add(backend); + return found; + } + const registry = registryFor({ os: 'ios' }); it('names a feature for every registry category', () => { @@ -394,4 +434,52 @@ describe('feature map', () => { expect(offenders.sort()).toEqual([]); }); + + it('provisions no backend neither API publishes for that feature', () => { + // The mirror of the case above, and the one that actually bites: a feature + // naming a backend nothing in its family exports makes every app using that + // feature download a library it can never load. + const offenders: string[] = []; + + for (const [category, node] of Object.entries(registry)) { + const feature = FEATURE_OF_CATEGORY[category]!; + // `llm` is the one category two features cover, so neither entry alone has + // to justify every backend it names. + if (feature === 'llm') continue; + + const published = allPublishedBackends(node); + for (const backend of FEATURE_MAP[feature].backends) { + if (!published.has(backend)) + offenders.push(`${feature} provisions ${backend}, unpublished`); + } + } + + expect(offenders.sort()).toEqual([]); + }); + + it('matches the feature table in the native libraries doc', () => { + // The table is the only place a user reads this map, so it drifting is the + // same bug as the map being wrong. + const doc = readFileSync( + join(__dirname, '../../../../docs/docs/03-core-and-advanced/08-native-libraries.md'), + 'utf8' + ); + const cell = (value: string) => (value === '—' ? [] : value.split(',').map((s) => s.trim())); + + const documented = new Map(); + for (const [, feature, backends, libs] of doc.matchAll( + /^\|\s*`(\w+)`\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|$/gm + )) { + if (FEATURE_MAP[feature!]) + documented.set(feature!, { backends: cell(backends!), libs: cell(libs!) }); + } + + expect([...documented.keys()].sort()).toEqual(Object.keys(FEATURE_MAP).sort()); + for (const [feature, row] of documented) { + expect([feature, row]).toEqual([ + feature, + { backends: FEATURE_MAP[feature].backends, libs: FEATURE_MAP[feature].libs }, + ]); + } + }); }); diff --git a/packages/react-native-executorch/__tests__/legacy/installGuard.test.ts b/packages/react-native-executorch/__tests__/legacy/installGuard.test.ts new file mode 100644 index 0000000000..c8c3937452 --- /dev/null +++ b/packages/react-native-executorch/__tests__/legacy/installGuard.test.ts @@ -0,0 +1,108 @@ +/** + * The legacy entry installs the JSI bindings as an import-time side effect, + * guarded by a check for globals that are already present. Opting out of a + * native library removes the per-task globals by design, so the guard reads + * only the two that are registered unconditionally. These tests pin that: a + * guard listing an opt-out-able global can never go false again, and install() + * would re-run on every module evaluation. + */ +const mockInstall = jest.fn(); + +jest.mock('../../legacy/src/native/RnExecutorchModules', () => ({ + ETInstallerNativeModule: { install: () => mockInstall() }, +})); + +// The entry re-exports the whole legacy surface, so importing it pulls in the +// model registry and, through it, a native event emitter that has no host here. +jest.mock('react-native-device-info', () => ({ isEmulatorSync: () => false })); + +// Every global RnExecutorchInstaller::injectJSIBindings registers outside an +// #ifdef. The guard reads only SENTINELS; the rest are here so the all-libs-on +// case is faithful. +const SENTINELS = ['loadExecutorchModule', '__rne_isEmulator'] as const; +const ALWAYS_REGISTERED = [ + ...SENTINELS, + 'loadLLM', + 'loadPrivacyFilter', + 'loadSpeechToText', + 'loadTextEmbeddings', + 'loadTokenizerModule', + 'loadVAD', +] as const; + +// Registered only when their native library is compiled in. +const OPENCV_BACKED = [ + 'loadOCR', + 'loadVerticalOCR', + 'loadClassification', + 'loadObjectDetection', + 'loadPoseEstimation', + 'loadStyleTransfer', + 'loadSemanticSegmentation', + 'loadInstanceSegmentation', + 'loadImageEmbeddings', + 'loadTextToImage', +] as const; +const PHONEMIS_BACKED = ['loadTextToSpeechKokoro'] as const; + +const ALL = [...ALWAYS_REGISTERED, ...OPENCV_BACKED, ...PHONEMIS_BACKED]; + +function setGlobals(names: readonly string[]) { + for (const name of ALL) delete (globalThis as any)[name]; + for (const name of names) { + (globalThis as any)[name] = name === '__rne_isEmulator' ? false : () => {}; + } +} + +function loadLegacyEntry() { + jest.isolateModules(() => { + require('../../legacy/src/index'); + }); +} + +let warn: jest.SpyInstance; +beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); +}); +afterEach(() => { + warn.mockRestore(); + setGlobals([]); +}); + +describe('the legacy install guard', () => { + it('installs when the bindings are not there yet', () => { + setGlobals([]); + loadLegacyEntry(); + expect(mockInstall).toHaveBeenCalledTimes(1); + }); + + it('does not reinstall once the unconditional globals are set', () => { + setGlobals(ALL); + loadLegacyEntry(); + expect(mockInstall).not.toHaveBeenCalled(); + }); + + it('does not reinstall when opencv was opted out', () => { + setGlobals([...ALWAYS_REGISTERED, ...PHONEMIS_BACKED]); + loadLegacyEntry(); + expect(mockInstall).not.toHaveBeenCalled(); + }); + + it('does not reinstall when phonemis was opted out', () => { + setGlobals([...ALWAYS_REGISTERED, ...OPENCV_BACKED]); + loadLegacyEntry(); + expect(mockInstall).not.toHaveBeenCalled(); + }); + + it('does not reinstall when both libraries were opted out', () => { + setGlobals(ALWAYS_REGISTERED); + loadLegacyEntry(); + expect(mockInstall).not.toHaveBeenCalled(); + }); + + it('still installs if a sentinel is missing', () => { + setGlobals([...OPENCV_BACKED, ...PHONEMIS_BACKED, '__rne_isEmulator']); + loadLegacyEntry(); + expect(mockInstall).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-native-executorch/android/build.gradle.kts b/packages/react-native-executorch/android/build.gradle.kts index 427227a6bf..f5af7ee6a4 100644 --- a/packages/react-native-executorch/android/build.gradle.kts +++ b/packages/react-native-executorch/android/build.gradle.kts @@ -144,8 +144,13 @@ android { getByName("main") { // Prebuilt executorch + backend .so live under // third-party/android/libs/executorch//. Pointing jniLibs here - // makes Gradle package them (libexecutorch.so + the enabled - // lib*_executorch_backend.so) into the APK alongside our own .so. + // makes Gradle package them into the APK alongside our own .so. + // + // Gradle packages every .so it finds under a jniLibs source dir, so + // this alone ships backends the app opted out of: -DRNE_ENABLE_* only + // stops CMake importing and linking them, which is enough for + // behaviour but has no bearing on packaging. `packaging.jniLibs + // .excludes` below drops the disabled ones back out. jniLibs.srcDirs("../third-party/android/libs/executorch") // Include generated codegen if using TurboModules java.srcDirs("${project.buildDir}/generated/source/codegen/java") @@ -160,6 +165,25 @@ android { packaging { // Prevents "Duplicate Library" errors with the React Native JSI engine resources.excludes.add("**/libjsi.so") + + // Ship only the backends the resolved config asks for. Without this an + // app declaring `backends: ["vulkan"]` still carries 2.67 MB of XNNPACK + // that nothing links, registers or loads, because jniLibs packages the + // directory rather than the link graph. This is the Android counterpart + // of the podspec deciding its -force_load entries from the same config. + // + // Keyed off the backend .so names rather than a staging dir so that a + // user who provisions third-party/ by hand gets the same treatment. + val backendLibs = mapOf( + "enableXnnpack" to "libxnnpack_executorch_backend.so", + "enableVulkan" to "libvulkan_executorch_backend.so" + ) + for ((flag, soName) in backendLibs) { + if (rneConfig[flag] == false) { + logger.lifecycle("[RnExecutorch] $flag is off; excluding $soName from the APK") + jniLibs.excludes.add("**/$soName") + } + } } compileOptions { diff --git a/packages/react-native-executorch/package.json b/packages/react-native-executorch/package.json index 8ad441ad13..c7e44a3edb 100644 --- a/packages/react-native-executorch/package.json +++ b/packages/react-native-executorch/package.json @@ -1,7 +1,7 @@ { "name": "react-native-executorch", "version": "0.10.2", - "nativeLibsVersion": "0.10.0", + "nativeLibsVersion": "0.10.2", "description": "An easy way to run AI models in React Native with ExecuTorch", "main": "./lib/module/index.js", "module": "./lib/module/index.js", diff --git a/packages/react-native-executorch/scripts/download-libs.js b/packages/react-native-executorch/scripts/download-libs.js index 82527b7d0c..00919e7733 100644 --- a/packages/react-native-executorch/scripts/download-libs.js +++ b/packages/react-native-executorch/scripts/download-libs.js @@ -11,9 +11,10 @@ * * headers.tar.gz -- ExecuTorch + c10 + torch + tokenizers + opencv * headers (platform-independent; always downloaded) - * core-android-arm64-v8a.tar.gz -- executorch, pthreadpool, cpuinfo for arm64 - * core-android-x86_64.tar.gz -- executorch for x86_64 - * core-ios.tar.gz -- ExecutorchLib.xcframework (without xnnpack/coreml) + * core-android-arm64-v8a.tar.gz -- libexecutorch.so (+ executorch.jar) for arm64; + * pthreadpool and cpuinfo are statically linked in + * core-android-x86_64.tar.gz -- libexecutorch.so for x86_64 + * core-ios.tar.gz -- ExecutorchLib.xcframework + libthreadpool_*.a * opencv-android-arm64-v8a.tar.gz -- OpenCV for arm64 * opencv-android-x86_64.tar.gz -- OpenCV for x86_64 * opencv-ios.tar.gz -- OpenCV xcframework @@ -151,8 +152,10 @@ const FEATURE_MAP = { // YOLO is xnnpack-only, ssdlite/rf_detr add coreml → union. objectDetection: { backends: ['xnnpack', 'coreml'], libs: ['opencv'] }, // Keypoint detection (#1280): BlazeFace + YOLO26-pose ship xnnpack; RF-DETR - // keypoint adds coreml + mlx → union. (Named to track the useKeypointDetector - // hook; main calls this poseEstimation.) + // keypoint adds coreml → union. mlx stays for the legacy API, whose + // RF_DETR_KEYPOINT_PREVIEW_MLX_FP32_MODEL outlived the new registry's MLX + // keypoint export (dropped in #1418). + // (Named to track the useKeypointDetector hook; main calls this poseEstimation.) keypointDetection: { backends: ['xnnpack', 'coreml', 'mlx'], libs: ['opencv'] }, // DeepLab/FCN/LR-ASPP/selfie all ship xnnpack + coreml. semanticSegmentation: { backends: ['xnnpack', 'coreml'], libs: ['opencv'] }, @@ -461,6 +464,68 @@ async function isCacheValid(artifact) { return expectedChecksum === actualChecksum; } +// The backend binaries each artifact owns, relative to the extraction dir. Used +// to clear stale copies: nothing else removes them, and `isCacheValid()` +// short-circuits the download, so turning a backend OFF after an install would +// otherwise leave the old binary in place and (on Android) keep shipping it. +const BACKEND_FILES = { + android: { + xnnpack: ['executorch/*/libxnnpack_executorch_backend.so'], + vulkan: ['executorch/*/libvulkan_executorch_backend.so'], + }, + ios: { + xnnpack: ['XnnpackBackend.xcframework'], + coreml: ['CoreMLBackend.xcframework'], + mlx: ['MLXBackend.xcframework', 'libs/executorch/mlx.metallib'], + }, +}; + +// Removes the binaries of backends the resolved config does not ask for, so a +// config change takes effect without anyone deleting third-party/ by hand. +function pruneDisabledBackends(targets, { backends }) { + for (const target of targets) { + const platform = target.startsWith('android') ? 'android' : 'ios'; + const destDir = + platform === 'android' + ? path.join(THIRD_PARTY_DIR, 'android', 'libs') + : path.join(THIRD_PARTY_DIR, 'ios'); + if (!fs.existsSync(destDir)) continue; + + for (const [backend, patterns] of Object.entries(BACKEND_FILES[platform])) { + if (backends.includes(backend)) continue; + for (const pattern of patterns) { + for (const stale of expandPattern(destDir, pattern)) { + console.log( + `[react-native-executorch] Removing ${backend} binary (not enabled): ${path.relative(THIRD_PARTY_DIR, stale)}` + ); + fs.rmSync(stale, { recursive: true, force: true }); + } + } + } + } +} + +// Resolves a path that may contain a single `*` segment (the Android ABI dir). +// A dependency-free stand-in for a glob, since this script runs at postinstall +// with nothing but Node's stdlib available. +function expandPattern(root, pattern) { + const segments = pattern.split('/'); + let candidates = [root]; + for (const segment of segments) { + const next = []; + for (const base of candidates) { + if (segment === '*') { + if (!fs.existsSync(base)) continue; + for (const entry of fs.readdirSync(base)) next.push(path.join(base, entry)); + } else { + next.push(path.join(base, segment)); + } + } + candidates = next; + } + return candidates.filter((candidate) => fs.existsSync(candidate)); +} + function extract(tarball, destDir) { ensureDir(destDir); // `-m` stamps extracted files with the extraction time instead of the mtime @@ -525,6 +590,10 @@ async function main() { console.log(` ✓ Done`); } + // Belt and braces to core no longer carrying backends: this also clears + // binaries left by an EARLIER install that had the backend enabled. + pruneDisabledBackends(targets, config); + console.log('[react-native-executorch] Native libs ready.'); } @@ -536,4 +605,12 @@ if (require.main === module) { }); } -module.exports = { ALL_BACKENDS, ALL_LIBS, FEATURE_MAP, findUserConfig, readUserConfig }; +module.exports = { + ALL_BACKENDS, + ALL_LIBS, + BACKEND_FILES, + FEATURE_MAP, + findUserConfig, + readUserConfig, + pruneDisabledBackends, +}; diff --git a/packages/react-native-executorch/scripts/package-release-artifacts.sh b/packages/react-native-executorch/scripts/package-release-artifacts.sh index acddc69cce..0ebee9dea3 100755 --- a/packages/react-native-executorch/scripts/package-release-artifacts.sh +++ b/packages/react-native-executorch/scripts/package-release-artifacts.sh @@ -8,15 +8,15 @@ # ./scripts/package-release-artifacts.sh # # Output: dist-artifacts/ -# core-android-arm64-v8a.tar.gz + .sha256 -# core-android-x86_64.tar.gz + .sha256 +# core-android-arm64-v8a.tar.gz + .sha256 (libexecutorch.so + executorch.jar) +# core-android-x86_64.tar.gz + .sha256 (libexecutorch.so) # opencv-android-arm64-v8a.tar.gz + .sha256 # opencv-android-x86_64.tar.gz + .sha256 # xnnpack-android-arm64-v8a.tar.gz + .sha256 # xnnpack-android-x86_64.tar.gz + .sha256 # vulkan-android-arm64-v8a.tar.gz + .sha256 # vulkan-android-x86_64.tar.gz + .sha256 -# core-ios.tar.gz + .sha256 +# core-ios.tar.gz + .sha256 (ExecutorchLib.xcframework + libthreadpool_*.a) # xnnpack-ios.tar.gz + .sha256 # coreml-ios.tar.gz + .sha256 # mlx-ios.tar.gz + .sha256 (device-slice xcframework + mlx.metallib resource) @@ -49,6 +49,11 @@ set -euo pipefail +# macOS tar writes AppleDouble `._*` sidecars for any file carrying extended +# attributes. They extract alongside the real libraries and are noise for +# anything globbing those directories. +export COPYFILE_DISABLE=1 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PACKAGE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" ANDROID_LIBS="$PACKAGE_ROOT/third-party/android/libs" @@ -174,18 +179,21 @@ echo "Android:" # statically linked into libexecutorch.so (not shipped separately). The # ABI-independent executorch.jar (ExecuTorch Java API for the JNI bridge) rides # along in the arm64 core tarball, which is always downloaded. +# Core carries the runtime ONLY. The backend .so ship in their own tarballs, so +# that an app which opted a backend out never downloads it -- and on Android, +# never packages it, since jniLibs bundles whatever sits in the directory. echo " → core-android-arm64-v8a" _ca_tmp=$(mktemp -d) mkdir -p "$_ca_tmp/executorch/arm64-v8a" -cp -r "$ANDROID_LIBS/executorch/arm64-v8a/." "$_ca_tmp/executorch/arm64-v8a/" +cp "$ANDROID_LIBS/executorch/arm64-v8a/libexecutorch.so" "$_ca_tmp/executorch/arm64-v8a/" cp "$ANDROID_LIBS/executorch.jar" "$_ca_tmp/executorch.jar" tar -czf "$OUT/core-android-arm64-v8a.tar.gz" -C "$_ca_tmp" . shasum -a 256 "$OUT/core-android-arm64-v8a.tar.gz" | awk '{print $1}' > "$OUT/core-android-arm64-v8a.tar.gz.sha256" echo " ✓ $(du -sh "$OUT/core-android-arm64-v8a.tar.gz" | cut -f1)" rm -rf "$_ca_tmp" -package_merged "core-android-x86_64" \ - "executorch/x86_64" "$ANDROID_LIBS/executorch/x86_64" +package_file "core-android-x86_64" \ + "executorch/x86_64" "$ANDROID_LIBS/executorch/x86_64/libexecutorch.so" package_merged "opencv-android-arm64-v8a" \ "opencv/arm64-v8a" "$ANDROID_LIBS/opencv/arm64-v8a" \ @@ -217,11 +225,30 @@ package_file "vulkan-android-x86_64" \ echo "" echo "iOS:" -# pthreadpool + cpuinfo are bundled into libthreadpool_*.a (in libs/executorch), -# so no separate libs/pthreadpool or libs/cpuinfo dirs are shipped. -package_merged "core-ios" \ - "ExecutorchLib.xcframework" "$IOS_DIR/ExecutorchLib.xcframework" \ - "libs/executorch" "$IOS_DIR/libs/executorch" +# pthreadpool + cpuinfo are bundled into libthreadpool_*.a, the only archives +# under libs/executorch/ the podspec references (as -force_load entries). The +# rest of that directory -- backend, kernel, kleidiai and llm archives -- is +# linked by nothing: the backends ship as xcframeworks in their own tarballs and +# everything else is already inside ExecutorchLib.xcframework. Shipping the +# directory wholesale cost ~36 MB of unreferenced device-slice .a per install, +# and made xnnpack-ios/coreml-ios/mlx-ios exact duplicates of what core already +# carried. +echo " → core-ios" +_ci_tmp=$(mktemp -d) +mkdir -p "$_ci_tmp/ExecutorchLib.xcframework" "$_ci_tmp/libs/executorch" +cp -r "$IOS_DIR/ExecutorchLib.xcframework/." "$_ci_tmp/ExecutorchLib.xcframework/" +for _tp in libthreadpool_ios.a libthreadpool_simulator.a; do + if [ ! -f "$IOS_DIR/libs/executorch/$_tp" ]; then + echo " ✗ Source file not found: $IOS_DIR/libs/executorch/$_tp" >&2 + rm -rf "$_ci_tmp" + exit 1 + fi + cp "$IOS_DIR/libs/executorch/$_tp" "$_ci_tmp/libs/executorch/" +done +tar -czf "$OUT/core-ios.tar.gz" -C "$_ci_tmp" . +shasum -a 256 "$OUT/core-ios.tar.gz" | awk '{print $1}' > "$OUT/core-ios.tar.gz.sha256" +echo " ✓ $(du -sh "$OUT/core-ios.tar.gz" | cut -f1)" +rm -rf "$_ci_tmp" # phonemis is built from in-tree source (third-party/common/phonemis submodule); # no iOS tarball is produced. From 56b8cc3038e1a65ddfc619c27a09e00b7fe9318a Mon Sep 17 00:00:00 2001 From: Mateusz Sluszniak <56299341+msluszniak@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:51:18 +0200 Subject: [PATCH 2/6] fix(llm): keep every terminal token out of the chat response (#1486) ## Description A `.pte` declares its own stop token ids, and they routinely outnumber `eos_token`. Qwen ends a turn with `<|im_end|>` but also stops on `<|endoftext|>`, which the tokenizer config only names as `pad_token`, so that one reached the user verbatim. 0.9 stripped `eos_token`, `eot_token` and `pad_token`; the chat session only dropped the first. - `parseTokenizerConfig` resolves `eot_token` and `pad_token` too and returns them as `stopTokens` (eos first, deduped). `eosToken` stays, so the change is additive. - The generation callback tests each chunk against the whole stop list rather than against `eos_token` alone. ExecuTorch decodes one token per callback, so a membership test covers it. Affects qwen3, qwen2.5 and hammer2.1, which all have `pad_token` different from `eos_token`. ### Introduces a breaking change? - [ ] Yes - [x] No ### Type of change - [x] Bug fix (change which fixes an issue) - [ ] New feature (change which adds functionality) - [ ] Documentation update (improves or adds clarity to existing documentation) - [ ] Other (chores, tests, code style improvements etc.) ### Tested on - [ ] iOS - [ ] Android ### Testing instructions - `yarn jest __tests__/tasks/llmChatSession.test.ts` in `packages/react-native-executorch`. - Two new cases: a terminal token named only as `pad_token`, and one named only as `eot_token`. Both fail on the current source and pass with this change. ### Screenshots ### Related issues ### Checklist - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have updated the documentation accordingly - [x] My changes generate no new warnings ### Additional notes Not run on a device: the change is TS only and the behaviour is covered by the unit tests above. Found while looking into Qwen3 output in software-mansion-labs/react-native-rag#23, where the adapter needed a `stopRegex: /<\|endoftext\|>/` to work around it. The other half of that report was the Qwen3 `chat_template`, which was byte identical to Qwen2.5's on the HF repo and is fixed there (`v0.10.0` now points at `5004436`), so it needs no change here. --------- Co-authored-by: Bartosz Hanc --- .../__tests__/tasks/llmChatSession.test.ts | 27 +++++++++++++++++++ .../extensions/llm/tasks/llmChatSession.ts | 10 +++---- .../extensions/llm/utils/tokenizerConfig.ts | 12 ++++++++- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/react-native-executorch/__tests__/tasks/llmChatSession.test.ts b/packages/react-native-executorch/__tests__/tasks/llmChatSession.test.ts index fa96453fb0..34b7b74cb2 100644 --- a/packages/react-native-executorch/__tests__/tasks/llmChatSession.test.ts +++ b/packages/react-native-executorch/__tests__/tasks/llmChatSession.test.ts @@ -22,6 +22,8 @@ const MODEL_PATH = '/models/llm.pte'; const TOKENIZER_PATH = '/models/tokenizer.json'; const TOKENIZER_CONFIG_PATH = '/models/tokenizer_config.json'; const EOS = '<|eot|>'; +const PAD = '<|endoftext|>'; +const EOT = '<|end_of_turn|>'; // A minimal but real Jinja chat template: the session renders the prompt with // `@huggingface/jinja`, so a hand-written string here exercises the same path a @@ -173,6 +175,31 @@ describe('createLLMChatSession — a turn', () => { expect(tokens).not.toContain(EOS); }); + // Qwen ends a turn with `<|im_end|>` but also stops on `<|endoftext|>`, which + // its config only names as `pad_token`, so `eos_token` alone does not cover it. + it('keeps a terminal token named only as pad_token out of the response', async () => { + writeTokenizerConfig({ pad_token: PAD }); // eslint-disable-line camelcase + fakeJsi.registerLLMRunner(MODEL_PATH, { generations: [{ response: `done ${PAD}` }] }); + const session = tracked(await createLLMChatSession(config)); + const tokens: string[] = []; + + const result = await session.sendMessage('hi', (token) => tokens.push(token)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(result.messages.at(-1)!.content).toBe('done '); + expect(tokens.join('')).not.toContain(PAD); + }); + + it('keeps a terminal token named only as eot_token out of the response', async () => { + writeTokenizerConfig({ eot_token: EOT }); // eslint-disable-line camelcase + fakeJsi.registerLLMRunner(MODEL_PATH, { generations: [{ response: `done ${EOT}` }] }); + const session = tracked(await createLLMChatSession(config)); + + const result = await session.sendMessage('hi'); + + expect(result.messages.at(-1)!.content).toBe('done '); + }); + it('stops generating as soon as the stop pattern matches', async () => { fakeJsi.registerLLMRunner(MODEL_PATH, { generations: [{ response: 'keep going STOP more' }] }); const session = tracked(await createLLMChatSession(config, { stopRegex: /STOP/ })); diff --git a/packages/react-native-executorch/src/extensions/llm/tasks/llmChatSession.ts b/packages/react-native-executorch/src/extensions/llm/tasks/llmChatSession.ts index 478f26696d..aab9b0f9fa 100644 --- a/packages/react-native-executorch/src/extensions/llm/tasks/llmChatSession.ts +++ b/packages/react-native-executorch/src/extensions/llm/tasks/llmChatSession.ts @@ -139,18 +139,18 @@ function generateChatTurnWorklet( prompt: Prompt, options: { readonly genConfig: LLMGenerationConfig; - readonly eosToken: string; + readonly stopTokens: readonly string[]; readonly stopRegex?: RegExp; readonly onToken?: (token: string) => void; } ): { readonly response: string; readonly stats: LLMGenerationStats } { 'worklet'; - const { genConfig, eosToken, stopRegex, onToken } = options; + const { genConfig, stopTokens, stopRegex, onToken } = options; let response = ''; const callback = (token: string) => { - if (token === eosToken) return; + if (stopTokens.includes(token)) return; response += token; if (onToken) scheduleOnRN(onToken, token); @@ -196,7 +196,7 @@ export async function createLLMChatSession( // Read and parse tokenizer_config.json const tokenizerConfigStr = await RNBlobUtil.fs.readFile(tokenizerConfigPath, 'utf8'); const tokenizerConfig = parseTokenizerConfig(JSON.parse(tokenizerConfigStr)); - const { chatTemplate, eosToken } = tokenizerConfig; + const { chatTemplate, stopTokens } = tokenizerConfig; // Prepare chat preprocessor const chatPreprocessorConfig = { chatTemplate, modalities, preprocessorConfig, tools }; @@ -233,7 +233,7 @@ export async function createLLMChatSession( genConfig?: LLMGenerationConfig ): Promise => { const turnGenConfig = { ...defaultGenerationConfig, ...genConfig }; - const generationOpts = { genConfig: turnGenConfig, eosToken, stopRegex, onToken }; + const generationOpts = { genConfig: turnGenConfig, stopTokens, stopRegex, onToken }; const initialCommitted = committed; const initialPos = runner.getKVCacheState().pos; diff --git a/packages/react-native-executorch/src/extensions/llm/utils/tokenizerConfig.ts b/packages/react-native-executorch/src/extensions/llm/utils/tokenizerConfig.ts index 5f3f11d346..9ae3582920 100644 --- a/packages/react-native-executorch/src/extensions/llm/utils/tokenizerConfig.ts +++ b/packages/react-native-executorch/src/extensions/llm/utils/tokenizerConfig.ts @@ -14,6 +14,10 @@ export type TokenizerChatConfig = { readonly chatTemplate: string; /** End-of-sequence token string. */ readonly eosToken: string; + /** + * Every terminal token the model may decode into its output. + */ + readonly stopTokens: readonly string[]; }; function resolveToken(token: unknown): string | undefined { @@ -57,5 +61,11 @@ export function parseTokenizerConfig(config: any): TokenizerChatConfig { ); } - return { chatTemplate, eosToken }; + const stopTokens = [ + eosToken, + resolveToken(config.eot_token), + resolveToken(config.pad_token), + ].filter((token, index, all): token is string => !!token && all.indexOf(token) === index); + + return { chatTemplate, eosToken, stopTokens }; } From adfd47c491ba00ae8423ce0061e623fa37ffd9fe Mon Sep 17 00:00:00 2001 From: Mateusz Sluszniak <56299341+msluszniak@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:08:12 +0200 Subject: [PATCH 3/6] fix(llm): pin the model load mode instead of inheriting upstream defaults (#1492) ## Description Neither factory was called with a load mode, so both inherited upstream's, and the two defaults are wrong in different ways. `create_multimodal_runner` defaults to `LoadMode::File`, which reads the whole `.pte` into a heap buffer. With `gemma4_e2b_mlx_int4.pte` (2.9 GB) that walks `phys_footprint` up in ~93 MB steps to 3296 MB and jetsam kills the app during load. `create_text_llm_runner` defaults to `MmapUseMlockIgnoreErrors`, which leaves footprint alone but wires the model resident. That is the number `DeviceInfo.getUsedMemory()` and every other memory API reachable from JS reports, so the new API looked like it used 6x the memory of the legacy one on the same model. Pin `Mmap` for both, which is what the legacy binding has always passed. ### Introduces a breaking change? - [ ] Yes - [x] No ### Type of change - [x] Bug fix (change which fixes an issue) - [ ] New feature (change which adds functionality) - [ ] Documentation update (improves or adds clarity to existing documentation) - [ ] Other (chores, tests, code style improvements etc.) ### Tested on - [x] iOS - [ ] Android ### Testing instructions iPhone 16 Release, gemma-4-e2b-mlx, after `load()`: | load mode | footprint | resident | | --- | --- | --- | | `File` | dies climbing past 3296 MB | n/a | | `MmapUseMlockIgnoreErrors` (old text default) | 930-954 MB | 3135-3282 MB | | `Mmap` (this PR, and legacy) | 950-952 MB | 355-475 MB | ### Related issues #1489 ### Checklist - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have updated the documentation accordingly - [x] My changes generate no new warnings ### Additional notes Neither mode changes what jetsam sees on the text path, so this is not the fix for #1489's prefill growth. It is what makes the new API's reported memory match the legacy API's. --- .../cpp/extensions/llm/llm_runner.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/react-native-executorch/cpp/extensions/llm/llm_runner.cpp b/packages/react-native-executorch/cpp/extensions/llm/llm_runner.cpp index 396b64c782..8212f3f3b0 100644 --- a/packages/react-native-executorch/cpp/extensions/llm/llm_runner.cpp +++ b/packages/react-native-executorch/cpp/extensions/llm/llm_runner.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -254,10 +255,16 @@ LLMRunnerHostObject::LLMRunnerHostObject(const std::string &modelPath, throw error::LoadFailed(std::format("LLMRunner: Failed to load runner tokenizer at path: {}", tokenizerPath)); } + // Plain mmap leaves the weights as clean, file-backed pages the kernel + // can evict, which is what the legacy binding has always passed. + constexpr auto kLoadMode = executorch::extension::Module::LoadMode::Mmap; + if (modalities_.empty()) { - runner_ = executorch::extension::llm::create_text_llm_runner(modelPath, std::move(tokenizer)); + runner_ = executorch::extension::llm::create_text_llm_runner( + modelPath, std::move(tokenizer), std::nullopt, -1.0f, "forward", kLoadMode); } else { - runner_ = executorch::extension::llm::create_multimodal_runner(modelPath, std::move(tokenizer)); + runner_ = executorch::extension::llm::create_multimodal_runner( + modelPath, std::move(tokenizer), std::nullopt, kLoadMode); } if (!runner_) { From aa6abcba56cc7e506e4f70da44a34eeb69ceea7a Mon Sep 17 00:00:00 2001 From: Mateusz Sluszniak <56299341+msluszniak@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:53:29 +0200 Subject: [PATCH 4/6] fix(llm): chunk prefill at the bound the graph declares (#1491) ## Description Upstream sizes prefill chunks from `get_max_seq_len`, which is the KV context budget rather than the widest tensor the graph accepts. `TextPrefiller` then never chunks, and any prompt past the real bound fails in `internal_resize_contiguous` with `Error::NotSupported`. Both gemma4 builds with a sliding window disagree: | model | `get_max_seq_len` | real `forward` bound | | --- | --- | --- | | `gemma4_e2b_mlx_int4` | 2048 | 511 | | `gemma_4_e2b_xnnpack_8da4w` | 4048 | 1024 | We now read the real bound from `method_meta(...).input_tensor_meta(0)` and lower the prefiller's chunk size to it, which is what the legacy runner has always done. `MultimodalPrefiller` has no chunking at all, so its text inputs are split into `TOKENS` pieces instead. ### Introduces a breaking change? - [ ] Yes - [x] No ### Type of change - [x] Bug fix (change which fixes an issue) - [ ] New feature (change which adds functionality) - [ ] Documentation update (improves or adds clarity to existing documentation) - [ ] Other (chores, tests, code style improvements etc.) ### Tested on - [x] iOS - [ ] Android ### Testing instructions `apps/nlp`, gemma-4-e2b-mlx, iPhone 16 Release. Send a prompt over 511 tokens (~6000 chars). A/B on the same binary: | prompt | before | after | | --- | --- | --- | | 407 tokens | ok | ok | | 1012 tokens | `Error::NotSupported`, KV position does not move | ok, 1495 MB peak, coherent output | ### Related issues #1489 ### Checklist - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have updated the documentation accordingly - [x] My changes generate no new warnings ### Additional notes Not gated on a backend. The clamp is a no-op when the two numbers agree, which is the case for the Vulkan gemma4 build (128/128) and for every other LLM checked, where the bound is just `max_seq_len - 1`. The legacy runner reads the same metadata but only when `uses_backend("MLXBackend")`, so it is still exposed on gemma4 XNNPACK. --- apps/nlp/constants/llm.ts | 1 - .../cpp/extensions/llm/llm_runner.cpp | 143 ++++++++++++++++-- .../cpp/extensions/llm/llm_runner.h | 7 + .../src/extensions/llm/llmRunner.ts | 2 - 4 files changed, 140 insertions(+), 13 deletions(-) diff --git a/apps/nlp/constants/llm.ts b/apps/nlp/constants/llm.ts index b311c707b4..161a663e9c 100644 --- a/apps/nlp/constants/llm.ts +++ b/apps/nlp/constants/llm.ts @@ -288,7 +288,6 @@ export function parseLlamaToolCalls(text: string): llm.ToolParserResult | undefi export const DEFAULT_GENERATION_CONFIG: llm.LLMGenerationConfig = { temperature: 0.7, maxNewTokens: 512, - echo: false, }; export const DEFAULT_SYSTEM_PROMPT = 'You are a helpful assistant. Your name is HAL-9000'; diff --git a/packages/react-native-executorch/cpp/extensions/llm/llm_runner.cpp b/packages/react-native-executorch/cpp/extensions/llm/llm_runner.cpp index 8212f3f3b0..56e10584e4 100644 --- a/packages/react-native-executorch/cpp/extensions/llm/llm_runner.cpp +++ b/packages/react-native-executorch/cpp/extensions/llm/llm_runner.cpp @@ -19,6 +19,8 @@ #include #include #include +#include +#include #include #include "core/conversions.h" @@ -57,8 +59,8 @@ namespace { // COUPLING / MAINTENANCE WARNING: // This creates a direct compile-time coupling with the internal implementation // details of ExecuTorch's `TextLLMRunner` and `MultimodalRunner`. If upstream -// ExecuTorch renames or alters `pos_`, `prefill_next_token_`, or `metadata_`, -// this code will fail to compile and must be adjusted accordingly. +// ExecuTorch renames or alters any member accessed below, this code will fail +// to compile and must be adjusted accordingly. // ============================================================================ template @@ -96,6 +98,96 @@ constexpr auto getPrivateMember(MMRunnerPrefillTag /*tag*/); template struct PrivateMemberAccessor; constexpr auto getPrivateMember(MMRunnerMetadataTag /*tag*/); +// Members that decide how a prompt is split into prefill chunks. +using executorch::extension::llm::TextPrefiller; + +struct TextRunnerModuleTag {}; +struct TextRunnerPrefillerTag {}; +struct PrefillerMaxSeqLenTag {}; +struct MMRunnerModuleTag {}; +struct MMRunnerTokenizerTag {}; + +template struct PrivateMemberAccessor; +constexpr auto getPrivateMember(TextRunnerModuleTag /*tag*/); + +template struct PrivateMemberAccessor; +constexpr auto getPrivateMember(TextRunnerPrefillerTag /*tag*/); + +template struct PrivateMemberAccessor; +constexpr auto getPrivateMember(PrefillerMaxSeqLenTag /*tag*/); + +template struct PrivateMemberAccessor; +constexpr auto getPrivateMember(MMRunnerModuleTag /*tag*/); + +template struct PrivateMemberAccessor; +constexpr auto getPrivateMember(MMRunnerTokenizerTag /*tag*/); + +// Upstream sizes prefill chunks from `get_max_seq_len`, which is the KV budget, +// not the widest token input the graph accepts (e.g. gemma4 MLX: 2048 vs 511). +// Prompts past the real bound then fail with `Error::NotSupported`. Returns the +// bound declared by the method's first input, or nullopt if it is not a +// rank>=2 integer tensor. +std::optional readPrefillBound(executorch::extension::Module *module, const std::string &methodName) { + if (module == nullptr) { + return std::nullopt; + } + auto meta = module->method_meta(methodName); + if (!meta.ok() || meta->num_inputs() == 0) { + return std::nullopt; + } + auto inputMeta = meta->input_tensor_meta(0); + if (!inputMeta.ok()) { + return std::nullopt; + } + const auto dtype = inputMeta->scalar_type(); + if (dtype != executorch::aten::ScalarType::Long && dtype != executorch::aten::ScalarType::Int) { + return std::nullopt; + } + const auto sizes = inputMeta->sizes(); + if (sizes.size() < 2) { + return std::nullopt; + } + const auto bound = static_cast(sizes[sizes.size() - 1]); + if (bound <= 0) { + return std::nullopt; + } + return bound; +} + +::tokenizers::Tokenizer *getRunnerTokenizer(executorch::extension::llm::IRunner *runner) { + auto *r = dynamic_cast(runner); + if (r == nullptr) { + return nullptr; + } + return (r->*getPrivateMember(MMRunnerTokenizerTag{})).get(); +} + +// `MultimodalPrefiller` does not chunk, so split text into token inputs that fit +// the bound; `MultimodalRunner` prefills them in order. +void appendTextInput(std::vector &inputs, + std::string text, + ::tokenizers::Tokenizer *tokenizer, + int64_t prefillBound) { + if (tokenizer == nullptr || prefillBound <= 0) { + inputs.emplace_back(std::move(text)); + return; + } + + auto encoded = tokenizer->encode(text, 0, 0); + if (!encoded.ok()) { + inputs.emplace_back(std::move(text)); + return; + } + + const auto &tokens = encoded.get(); + const auto chunk = static_cast(prefillBound); + for (size_t offset = 0; offset < tokens.size(); offset += chunk) { + const auto end = std::min(offset + chunk, tokens.size()); + inputs.emplace_back(std::vector(tokens.begin() + static_cast(offset), + tokens.begin() + static_cast(end))); + } +} + int64_t getRunnerPos(executorch::extension::llm::IRunner *runner, bool isMultimodal) { if (runner == nullptr) { return 0; @@ -179,10 +271,14 @@ std::vector parsePrompt( jsi::Runtime &rt, const std::string &ctx, const jsi::Value &value, - const std::vector &supportedModalities) { + const std::vector &supportedModalities, + ::tokenizers::Tokenizer *tokenizer, + int64_t prefillBound) { if (value.isString()) { - return {executorch::extension::llm::MultimodalInput(conversions::asType(rt, ctx, value))}; + std::vector inputs; + appendTextInput(inputs, conversions::asType(rt, ctx, value), tokenizer, prefillBound); + return inputs; } auto arr = conversions::asType(rt, ctx, value); @@ -196,7 +292,7 @@ std::vector parsePrompt( std::string itemCtx = std::format("{}[{}]", ctx, i); if (elem.isString()) { - inputs.emplace_back(conversions::asType(rt, itemCtx, elem)); + appendTextInput(inputs, conversions::asType(rt, itemCtx, elem), tokenizer, prefillBound); continue; } auto mediaObj = conversions::asType(rt, itemCtx, elem); @@ -276,6 +372,33 @@ LLMRunnerHostObject::LLMRunnerHostObject(const std::string &modelPath, std::string errorMsg = executorch::runtime::to_string(loadStatus); throw error::LoadFailed(std::format("LLMRunner: Failed to load model: {}", errorMsg), loadStatus); } + + // Clamp prefill chunks to the graph's real bound (see `readPrefillBound`). + // Text models take tokens on `forward`, multimodal ones on `token_embedding`. + if (modalities_.empty()) { + auto *textRunner = dynamic_cast(runner_.get()); + if (textRunner != nullptr) { + const auto &module = textRunner->*getPrivateMember(TextRunnerModuleTag{}); + const auto bound = readPrefillBound(module.get(), "forward"); + auto &prefiller = textRunner->*getPrivateMember(TextRunnerPrefillerTag{}); + if (bound.has_value() && prefiller) { + // Upstream allows max_seq_len < max_context_len and chunks itself. + auto &maxSeqLen = (*prefiller).*getPrivateMember(PrefillerMaxSeqLenTag{}); + maxSeqLen = std::min(maxSeqLen, *bound); + prefillBound_ = maxSeqLen; + } + } + } else { + auto *mmRunner = dynamic_cast(runner_.get()); + if (mmRunner != nullptr) { + const auto &module = mmRunner->*getPrivateMember(MMRunnerModuleTag{}); + const auto bound = + readPrefillBound(module.get(), executorch::extension::llm::kTokenEmbeddingMethod); + if (bound.has_value()) { + prefillBound_ = *bound; + } + } + } } std::unique_lock LLMRunnerHostObject::tryLockUnique(std::string_view ctx) { @@ -312,11 +435,9 @@ jsi::Value LLMRunnerHostObject::get(jsi::Runtime &rt, const jsi::PropNameID &nam } executorch::extension::llm::GenerationConfig config; + config.echo = false; if (count > 1 && !args[1].isUndefined() && !args[1].isNull()) { auto configObj = conversions::asType(rt, "LLMRunner.generate: config", args[1]); - if (auto echoOpt = conversions::getOptionalProperty(rt, "LLMRunner.generate: config", configObj, "echo")) { - config.echo = *echoOpt; - } if (auto ignoreEosOpt = conversions::getOptionalProperty(rt, "LLMRunner.generate: config", configObj, "ignoreEos")) { config.ignore_eos = *ignoreEosOpt; } @@ -355,7 +476,8 @@ jsi::Value LLMRunnerHostObject::get(jsi::Runtime &rt, const jsi::PropNameID &nam auto prompt = conversions::asType(rt, "LLMRunner.generate: prompt", args[0]); genStatus = self->runner_->generate(prompt, config, tokenCallback, statsCallback); } else { - auto inputs = parsePrompt(rt, "LLMRunner.generate", args[0], self->modalities_); + auto inputs = parsePrompt(rt, "LLMRunner.generate", args[0], self->modalities_, + getRunnerTokenizer(self->runner_.get()), self->prefillBound_); auto *multimodalRunner = dynamic_cast(self->runner_.get()); if (multimodalRunner == nullptr) { throw error::InvalidArgument("LLMRunner.generate: Runner instance is not a multimodal model"); @@ -382,7 +504,8 @@ jsi::Value LLMRunnerHostObject::get(jsi::Runtime &rt, const jsi::PropNameID &nam auto lock = self->tryLockUnique("LLMRunner.prefill"); - auto inputs = parsePrompt(rt, "LLMRunner.prefill", args[0], self->modalities_); + auto inputs = parsePrompt(rt, "LLMRunner.prefill", args[0], self->modalities_, + getRunnerTokenizer(self->runner_.get()), self->prefillBound_); auto result = self->runner_->prefill(inputs); if (!result.ok()) { diff --git a/packages/react-native-executorch/cpp/extensions/llm/llm_runner.h b/packages/react-native-executorch/cpp/extensions/llm/llm_runner.h index 27c3cb664c..841e36f977 100644 --- a/packages/react-native-executorch/cpp/extensions/llm/llm_runner.h +++ b/packages/react-native-executorch/cpp/extensions/llm/llm_runner.h @@ -8,6 +8,7 @@ #pragma clang diagnostic ignored "-Wdeprecated-declarations" #endif +#include #include #include #include @@ -77,6 +78,12 @@ class LLMRunnerHostObject final : public facebook::jsi::HostObject, std::string tokenizerPath_; /** List of supported non-text input modalities. */ std::vector modalities_; + /** + * Longest token sequence the loaded graph accepts in one prefill step, read + * off the model's own method metadata. 0 when the model does not declare + * one, in which case prompts are passed through unsplit. + */ + int64_t prefillBound_ = 0; }; void install_createLLMRunner(facebook::jsi::Runtime &rt, facebook::jsi::Object &module); diff --git a/packages/react-native-executorch/src/extensions/llm/llmRunner.ts b/packages/react-native-executorch/src/extensions/llm/llmRunner.ts index ede9ca1c85..ce3867fc7a 100644 --- a/packages/react-native-executorch/src/extensions/llm/llmRunner.ts +++ b/packages/react-native-executorch/src/extensions/llm/llmRunner.ts @@ -13,8 +13,6 @@ declare const llmRunnerBrand: unique symbol; * @category LLM / Types */ export type LLMGenerationConfig = { - /** Whether to echo the prompt in the generated output. */ - readonly echo?: boolean; /** Whether to ignore EOS tokens during generation. */ readonly ignoreEos?: boolean; /** Maximum number of new tokens to generate. */ From e73e17fa10f8a3c9766f923ae1b35a74621d8048 Mon Sep 17 00:00:00 2001 From: Mateusz Sluszniak <56299341+msluszniak@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:05:07 +0200 Subject: [PATCH 5/6] fix(install): make the native lib download work on Windows (#1494) ## Description The postinstall downloader shelled out through shell strings twice, and both broke on Windows. - `sha256()` used `sha256sum`, which prefixes its whole output line with a backslash when the filename holds one, so every hash of a `C:\...` cache path came back as `\` and no artifact validated. Now hashed in-process with `crypto`, chunked. - `extract()` passed the tarball as `-f `, which GNU tar reads as `host:file` and tries to resolve a host named `C`. The archive now arrives on stdin and `tar` runs with `cwd` set to the target dir, since GNU tar also unquotes `-C` paths (`\r`, `\t` in `node_modules\react-native-executorch\third-party`). A missing or failing `tar` now says which archive and why. - Also documents `tar` as a requirement and that Windows/Linux hosts build for Android only. ### Introduces a breaking change? - [ ] Yes - [x] No ### Type of change - [x] Bug fix (change which fixes an issue) - [ ] New feature (change which adds functionality) - [ ] Documentation update (improves or adds clarity to existing documentation) - [ ] Other (chores, tests, code style improvements etc.) ### Tested on - [x] iOS - [x] Android ### Testing instructions - Full postinstall run against `v0.10.4-libs`: all 13 artifacts downloaded, verified and extracted; second run reports 13 cache hits. - New `__tests__/api/downloadLibs.test.ts` covers both regressions with backslash-bearing paths, plus a >1 MiB file and the failure message. ### Screenshots ### Related issues Fixes #1493 ### Checklist - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have updated the documentation accordingly - [x] My changes generate no new warnings ### Additional notes Verified on Windows 11 with GNU tar (Git for Windows) and bsdtar. --------- Co-authored-by: Claude Opus 5 (1M context) --- .../__tests__/api/downloadLibs.test.ts | 106 ++++++++++++++++++ .../scripts/download-libs.js | 61 +++++++++- 2 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 packages/react-native-executorch/__tests__/api/downloadLibs.test.ts diff --git a/packages/react-native-executorch/__tests__/api/downloadLibs.test.ts b/packages/react-native-executorch/__tests__/api/downloadLibs.test.ts new file mode 100644 index 0000000000..9c47122031 --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/downloadLibs.test.ts @@ -0,0 +1,106 @@ +/** + * The install-time hashing and extraction in `scripts/download-libs.js`. + * + * Both steps used to shell out through a shell string, and both broke on + * Windows in ways that made the package impossible to install there (#1493): + * `sha256sum` prefixes its whole output line with a backslash when the filename + * contains one, so every hash of a `C:\...` path came back as `\` and no + * artifact validated; and `tar -f C:\...` is the `host:file` form to GNU tar, + * which then tried to resolve a host named `C`. GNU tar also unquotes `-C`, so + * the `\r` and `\t` of `\react-native-executorch\third-party` turned into + * control characters. These suites pin the + * shell-free replacements against paths that reproduce both. + */ +import { createHash, randomBytes } from 'crypto'; +import { execFileSync } from 'child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const { sha256, extract } = require('../../scripts/download-libs.js'); + +let workDir: string; + +beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'rnet-download-libs-')); +}); + +afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); +}); + +describe('sha256', () => { + it('hashes a file', () => { + // Larger than the 1 MiB read buffer, so the chunked loop is exercised. + const contents = randomBytes(3 * 1024 * 1024 + 17); + const file = join(workDir, 'artifact.tar.gz'); + writeFileSync(file, contents); + + expect(sha256(file)).toBe(createHash('sha256').update(contents).digest('hex')); + }); + + it('hashes a file whose name contains a backslash', () => { + const contents = randomBytes(64); + // Stands in for the Windows path that broke `sha256sum`; a backslash is an + // ordinary filename character here, so this runs on every platform. + const file = join(workDir, 'C\\Users\\artifact.tar.gz'); + writeFileSync(file, contents); + + expect(sha256(file)).toBe(createHash('sha256').update(contents).digest('hex')); + }); + + it('hashes an empty file', () => { + const file = join(workDir, 'empty.tar.gz'); + writeFileSync(file, ''); + + expect(sha256(file)).toBe(createHash('sha256').update('').digest('hex')); + }); +}); + +describe('extract', () => { + /** Builds a gzipped tarball holding `executorch/libexecutorch.so`. */ + function makeTarball(name: string) { + const stageDir = join(workDir, 'stage'); + mkdirSync(join(stageDir, 'executorch'), { recursive: true }); + writeFileSync(join(stageDir, 'executorch', 'libexecutorch.so'), 'payload'); + const tarball = join(workDir, name); + execFileSync('tar', ['-czf', tarball, '-C', stageDir, 'executorch']); + return tarball; + } + + it('unpacks the archive into a destination that does not exist yet', () => { + const tarball = makeTarball('core-android-arm64-v8a.tar.gz'); + const destDir = join(workDir, 'third-party', 'android', 'libs'); + + extract(tarball, destDir); + + expect(readFileSync(join(destDir, 'executorch', 'libexecutorch.so'), 'utf8')).toBe('payload'); + }); + + it('unpacks an archive whose path contains a backslash', () => { + const tarball = makeTarball('C\\cache\\core-android-arm64-v8a.tar.gz'); + const destDir = join(workDir, 'dest'); + + extract(tarball, destDir); + + expect(existsSync(join(destDir, 'executorch', 'libexecutorch.so'))).toBe(true); + }); + + it('unpacks into a destination whose path contains backslash escapes', () => { + const tarball = makeTarball('core-android-arm64-v8a.tar.gz'); + // `\r` and `\t` are what GNU tar's default unquoting turns into control + // characters, as in `node_modules\react-native-executorch\third-party`. + const destDir = join(workDir, 'node_modules\\react-native-executorch\\third-party'); + + extract(tarball, destDir); + + expect(existsSync(join(destDir, 'executorch', 'libexecutorch.so'))).toBe(true); + }); + + it('reports which archive failed rather than a bare exit code', () => { + const tarball = join(workDir, 'truncated.tar.gz'); + writeFileSync(tarball, randomBytes(128)); + + expect(() => extract(tarball, join(workDir, 'dest'))).toThrow(/truncated\.tar\.gz/); + }); +}); diff --git a/packages/react-native-executorch/scripts/download-libs.js b/packages/react-native-executorch/scripts/download-libs.js index 00919e7733..64b180b6da 100644 --- a/packages/react-native-executorch/scripts/download-libs.js +++ b/packages/react-native-executorch/scripts/download-libs.js @@ -75,7 +75,9 @@ const https = require('https'); const http = require('http'); const fs = require('fs'); const path = require('path'); -const { execSync } = require('child_process'); +const { spawnSync } = require('child_process'); +const { createHash } = require('crypto'); +const { Buffer } = require('buffer'); // ---- Config ---------------------------------------------------------------- @@ -434,15 +436,33 @@ function download(url, dest) { }; get(url); file.on('error', (err) => { - fs.unlinkSync(dest); + // Close before removing: on Windows an open handle makes the unlink fail + // with EBUSY, which then replaces the real error with the cleanup's. And + // `force` covers the case where the file was never created. + file.close(() => fs.rmSync(dest, { force: true })); reject(err); }); }); } +// Hashes in-process rather than shelling out to `sha256sum` / `shasum`. Those +// are not on PATH everywhere, and GNU `sha256sum` escapes a filename holding a +// backslash by prefixing its whole output line with one -- so on Windows every +// hash came back as `\` and no artifact could ever validate. Read in +// chunks: some artifacts are hundreds of megabytes and this runs at install. function sha256(filePath) { - const result = execSync(`sha256sum "${filePath}" || shasum -a 256 "${filePath}"`); - return result.toString().split(' ')[0].trim(); + const hash = createHash('sha256'); + const buffer = Buffer.alloc(1024 * 1024); + const fd = fs.openSync(filePath, 'r'); + try { + let bytesRead; + while ((bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) { + hash.update(buffer.subarray(0, bytesRead)); + } + } finally { + fs.closeSync(fd); + } + return hash.digest('hex'); } async function isCacheValid(artifact) { @@ -536,7 +556,36 @@ function extract(tarball, destDir) { // and they get archived and linked against the new libraries -- which shows // up as an undefined symbol for whatever API changed between the two // releases, far away from the actual cause. - execSync(`tar -xzmf "${tarball}" -C "${destDir}"`); + // + // No path reaches tar's argv, because GNU tar (Git for Windows' is the one + // on PATH under Git Bash) mangles Windows paths two ways: `-f C:\Users\...` + // is the `host:file` form, so it tries to reach a remote host named `C`; and + // `-C` is unquoted by default, so the `\r` and `\t` in + // `node_modules\react-native-executorch\third-party` become a carriage + // return and a tab. The archive arrives on stdin and `cwd` does the chdir. + const fd = fs.openSync(tarball, 'r'); + let result; + try { + result = spawnSync('tar', ['-xzm', '-f', '-'], { + cwd: destDir, + stdio: [fd, 'inherit', 'pipe'], + encoding: 'utf8', + }); + } finally { + fs.closeSync(fd); + } + if (result.error) { + throw new Error( + `Could not run \`tar\` to extract ${tarball}: ${result.error.message}. ` + + 'Install tar (it ships with Windows 10 1803+, Git for Windows and every Unix) and reinstall.' + ); + } + if (result.status !== 0) { + throw new Error( + `tar exited with ${result.status ?? result.signal} while extracting ${tarball}` + + `${result.stderr ? `:\n${result.stderr.trim()}` : ''}` + ); + } } // ---- Main ------------------------------------------------------------------ @@ -613,4 +662,6 @@ module.exports = { findUserConfig, readUserConfig, pruneDisabledBackends, + sha256, + extract, }; From c3f8454b667f9d25f1edb713f59af6a6cf6c06b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Fri, 25 Sep 2026 18:54:13 +0200 Subject: [PATCH 6/6] fix(libs): resolve the v0.10.4 native artifacts v0.10.4-libs carries the XNNPACK weights-cache fixes: the PReLU use-after-free and the retained unpacked weights that got Kokoro XNNPACK killed on iPhone. --- packages/react-native-executorch/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-executorch/package.json b/packages/react-native-executorch/package.json index c7e44a3edb..16f8ea085c 100644 --- a/packages/react-native-executorch/package.json +++ b/packages/react-native-executorch/package.json @@ -1,7 +1,7 @@ { "name": "react-native-executorch", "version": "0.10.2", - "nativeLibsVersion": "0.10.2", + "nativeLibsVersion": "0.10.4", "description": "An easy way to run AI models in React Native with ExecuTorch", "main": "./lib/module/index.js", "module": "./lib/module/index.js",