From 44b4399787748b81659c5a222f5673beb967d162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Wed, 2 Sep 2026 17:25:53 +0200 Subject: [PATCH 1/4] fix(download-libs): stamp extracted artifacts with the extraction time `tar -xzf` keeps the mtime recorded in the archive, so extracted headers carry the timestamp of the release cut rather than of the extraction. Upgrading to a newer artifacts release can therefore install headers that look OLDER than object files from an earlier build. Ninja compares those mtimes, decides the objects are up to date, and never recompiles them -- then links them against the new libraries. The result is an undefined symbol for whichever API changed between the two releases, reported against whatever happens to call it. Hitting it with the 1.4.1 artifacts, `make_tensor_ptr` gained a trailing `Device` parameter, so a stale object kept referencing the old 7-argument symbol that the new libexecutorch.so no longer exports: ld.lld: error: undefined symbol: executorch::extension::make_tensor_ptr( std::vector, void*, std::vector, std::vector, ScalarType, TensorShapeDynamism, std::function) >>> referenced by tensor_ptr.h:91 >>> neural_phonemizer.cpp.o in archive phonemis/libphonemis.a `-m` stamps extracted files with the extraction time, so refreshed headers always look newer than existing objects and dependents rebuild. --- .../react-native-executorch/scripts/download-libs.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/react-native-executorch/scripts/download-libs.js b/packages/react-native-executorch/scripts/download-libs.js index 828ee6f6e0..106044b314 100644 --- a/packages/react-native-executorch/scripts/download-libs.js +++ b/packages/react-native-executorch/scripts/download-libs.js @@ -400,7 +400,15 @@ function isCacheValid(artifact) { function extract(tarball, destDir) { ensureDir(destDir); - execSync(`tar -xzf "${tarball}" -C "${destDir}"`); + // `-m` stamps extracted files with the extraction time instead of the mtime + // recorded in the archive. Without it, headers keep the timestamp they had + // when the release was cut, so upgrading to a NEWER artifacts release can + // hand ninja headers that look OLDER than object files from a previous build. + // Ninja then treats those objects as up to date and never recompiles them, + // 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}"`); } // ---- Main ------------------------------------------------------------------ From 67cab10f23ed32601b5ed7dbdf32ad07132a6946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Wed, 2 Sep 2026 17:36:17 +0200 Subject: [PATCH 2/4] fix(download-libs): validate the cache against the remote checksum `isCacheValid` compared the cached tarball against the CACHED checksum, so a stale cache validated itself. Since the cache directory is keyed only on the libs version, a release re-cut at the same version was never picked up: every artifact reported a cache hit and the old files were reused indefinitely, while a fresh clone got the new ones. Two machines on the same pin could hold different artifacts with no way to tell them apart. Fetch the checksum from the release before trusting the cache, falling back to the cached copy when it cannot be reached so an already-populated cache still builds offline. --- .../scripts/download-libs.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/react-native-executorch/scripts/download-libs.js b/packages/react-native-executorch/scripts/download-libs.js index 106044b314..983cce2a0e 100644 --- a/packages/react-native-executorch/scripts/download-libs.js +++ b/packages/react-native-executorch/scripts/download-libs.js @@ -390,9 +390,20 @@ function sha256(filePath) { return result.toString().split(' ')[0].trim(); } -function isCacheValid(artifact) { +async function isCacheValid(artifact) { if (!fs.existsSync(artifact.cacheFile)) return false; - if (!fs.existsSync(artifact.cacheChecksumFile)) return false; + // Refresh the checksum from the release before trusting the cache. Comparing + // a cached tarball against a CACHED checksum lets a stale cache validate + // itself: the cache directory is keyed on the libs version, so a release + // re-cut at the same version is never picked up -- every artifact reports a + // cache hit and the old files are reused indefinitely. + try { + await download(artifact.checksumUrl, artifact.cacheChecksumFile); + } catch { + // Unreachable checksum (offline, rate limited): fall back to the cached + // one so an already-populated cache still builds without a network. + if (!fs.existsSync(artifact.cacheChecksumFile)) return false; + } const expectedChecksum = fs.readFileSync(artifact.cacheChecksumFile, 'utf8').trim(); const actualChecksum = sha256(artifact.cacheFile); return expectedChecksum === actualChecksum; @@ -440,7 +451,7 @@ async function main() { for (const artifact of artifacts) { console.log(`[react-native-executorch] Preparing ${artifact.name}...`); - if (isCacheValid(artifact)) { + if (await isCacheValid(artifact)) { console.log(` ✓ Cache hit, skipping download`); } else { console.log(` ↓ Downloading ${artifact.url}`); From 3b2e53617eb53efc34a2636de45a7bae3e8f26a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Wed, 2 Sep 2026 17:45:46 +0200 Subject: [PATCH 3/4] test(native): build the host test deps against ExecuTorch 1.4.1 The `v0.10.0-libs` artifacts now carry ExecuTorch 1.4.1 headers, and the pin above `EXECUTORCH_VERSION` requires it to track the release that `third-party/include` is vendored from. Left at v1.3.1 the host tests compiled 1.4.1 headers against a 1.3.1 build, and `make_tensor_ptr` gained a trailing `Device` parameter in between: undefined reference to `executorch::extension::make_tensor_ptr( std::vector, void*, std::vector, std::vector, ScalarType, TensorShapeDynamism, std::function, etensor::Device)' The tokenizers pin moves with it. headers.tar.gz ships the fork's tokenizers headers, and the released copies match a03231a2 rather than 56a30afb, verified by hashing hf_tokenizer.h against both commits. That one matters more than a link error: as the comment notes, a class layout mismatch here crashes inside setup_pretokenizer at runtime instead of failing to link. Editing this file also changes the dependency cache key, so the deps rebuild rather than restoring the 1.3.1 tree. --- .../scripts/build-native-test-deps.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-native-executorch/scripts/build-native-test-deps.sh b/packages/react-native-executorch/scripts/build-native-test-deps.sh index de118ce439..f5e5e8381e 100755 --- a/packages/react-native-executorch/scripts/build-native-test-deps.sh +++ b/packages/react-native-executorch/scripts/build-native-test-deps.sh @@ -32,11 +32,11 @@ HERMES_REPO="https://github.com/facebook/hermes.git" # errors or, worse, ABI drift at runtime. cpp/extensions/llm additionally reads # private members of TextLLMRunner/MultimodalRunner, so a version skew there # fails to compile rather than silently misbehaving. -EXECUTORCH_VERSION="v1.3.1" +EXECUTORCH_VERSION="v1.4.1" EXECUTORCH_REPO="https://github.com/pytorch/executorch.git" # The shipped native libraries are built from software-mansion-labs/executorch -# @rne-split-build, which is ExecuTorch 1.3.1 with the tokenizers submodule +# @ms/separate-backends-1.4.1, which is ExecuTorch 1.4.1 with the tokenizers submodule # swapped for the fork below (it adds the WordPiece/Unigram models and the NFC # normalizer that upstream has not taken). third-party/include carries that # fork's headers, so linking upstream's libtokenizers.a here would compile @@ -47,7 +47,7 @@ EXECUTORCH_REPO="https://github.com/pytorch/executorch.git" # Keep this commit in sync with the tokenizers submodule of the fork commit that # produced the current headers.tar.gz. TOKENIZERS_REPO="https://github.com/software-mansion-labs/pytorch-tokenizers.git" -TOKENIZERS_COMMIT="56a30afbe2e6b4ca881d0fb7b961b9f9da156be4" +TOKENIZERS_COMMIT="a03231a20a72036bf9a8e4a3b1d63494b90da1a6" cd "$(dirname "$0")/.." PACKAGE_DIR="$(pwd)" From 398fb4b8e8fef2949f7f66ca61340cd31952f9c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Wed, 2 Sep 2026 18:04:21 +0200 Subject: [PATCH 4/4] ci: fail fast when the headers and test-dependency ExecuTorch disagree third-party/include comes from the artifacts release while the host tests build ExecuTorch from source at a pin in scripts/build-native-test-deps.sh. Nothing checked that the two matched, so re-cutting the artifacts without moving the pin compiled 1.4.1 headers against a 1.3.1 build. That surfaced nine minutes into the dependency build as an undefined reference to a mangled symbol, attributed to whichever code happened to call the changed API rather than to the drift. Compare the two before the build and name both values. Scope is release to release: ET_VERSION is generated from the source tree's version.txt, so it cannot distinguish the fork's patches from upstream and says nothing about TOKENIZERS_COMMIT. --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2d6d56994..97dcecb5d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,34 @@ jobs: - name: Provision third-party headers run: RNET_HEADERS_ONLY=1 node scripts/download-libs.js + # third-party/include and the ExecuTorch built below must come from the + # same release. When they drifted, `make_tensor_ptr` gaining a trailing + # `Device` parameter surfaced as an undefined reference nine minutes into + # the dependency build, blamed on whichever code happened to call it. + # Comparing the two up front fails in seconds and names both values. + # Note this only spans releases: ET_VERSION comes from the source tree's + # version.txt, so it cannot tell the fork's patches apart from upstream, + # and it says nothing about TOKENIZERS_COMMIT. + - name: Check the header and test-dependency ExecuTorch versions agree + run: | + headers=$(sed -n 's/^#define ET_VERSION "\(.*\)"$/\1/p' \ + third-party/include/executorch/runtime/core/version.h) + pinned=$(sed -n 's/^EXECUTORCH_VERSION="v\(.*\)"$/\1/p' \ + scripts/build-native-test-deps.sh) + if [ -z "$headers" ] || [ -z "$pinned" ]; then + echo "::error::could not read the ExecuTorch versions" \ + "(headers='$headers' pinned='$pinned')" + exit 1 + fi + if [ "$headers" != "$pinned" ]; then + echo "::error::third-party/include is ExecuTorch $headers but" \ + "scripts/build-native-test-deps.sh pins v$pinned." \ + "Bump EXECUTORCH_VERSION (and TOKENIZERS_COMMIT) to match" \ + "the artifacts release." + exit 1 + fi + echo "ExecuTorch $headers on both sides" + # Hermes and ExecuTorch are pinned to exact tags, so the cache only misses # when scripts/build-native-test-deps.sh changes those pins. #