Skip to content

UniFFI checksum mismatch on armeabi-v7a Android builds (115 Sentry users) #1339

Description

@nbradbury

Summary

uniffi.wp_api.IntegrityCheckingUniffiLib.<clinit> throws RuntimeException: UniFFI API checksum mismatch on a subset of Android devices in production, almost exclusively 32-bit armeabi-v7a split APKs. The embedded Kotlin-side checksums don't match the checksums queried from the packaged native .so at load time.

Sentry impact (last 90 days, version alpha-2026-04-20):

Combined: 115 users. Affected devices include Galaxy A02 (SM-A022F), Redmi 6, and similar 32-bit ARM hardware. Once <clinit> fails the JVM caches the failure, so every subsequent call to anything in uniffi.wp_api throws NoClassDefFoundError — that's why per-user event counts are high.

Note: This JPAndroid draft PR attempted to resolve the problem by catching the LinkageError, but the real source of the problem is in this repo.

Reproduction stack trace

ExceptionInInitializerError
  at uniffi.wp_mobile.WpService$Companion.wordpressCom-E0BElUM(wp_mobile.kt:8016)
  at uniffi.wp_mobile.UniffiLib.<clinit>(wp_mobile.kt:906)
  at uniffi.wp_api.Wp_apiKt.uniffiEnsureInitialized(wp_api.kt:6571)
Caused by: RuntimeException: UniFFI API checksum mismatch: try cleaning and rebuilding your project
  at uniffi.wp_api.Wp_apiKt.uniffiCheckApiChecksums(wp_api.kt:4388)
  at uniffi.wp_api.IntegrityCheckingUniffiLib.<clinit>(wp_api.kt:867)

Investigation findings

1. UniFFI bindgen reads from the host .so, not the Android .so

native/kotlin/build.gradle.kts (around lines 34-35):

val nativeLibraryPath =
    "$cargoProjectRoot/target/release/lib${rustPrimaryModule}${getNativeLibraryExtension()}"

That path resolves to the host-built library (Linux x86_64 on Buildkite), produced by cargoBuildLibraryRelease which runs cargo build --package wp_mobile --release without a --target flag. generateUniFFIBindings in native/kotlin/api/kotlin/build.gradle.kts passes this path as --library <nativeLibraryPath> to wp_uniffi_bindgen generate. So the Kotlin checksums are baked from the host binary, then validated at runtime against the per-ABI Android .so files shipped in the AAR.

For this to work correctly, UniFFI checksums must be identical across compilation targets. They aren't, for armeabi-v7a.

2. The 5 Android build agents run independently, with no pinned Rust toolchain

.buildkite/pipeline.yml (introduced/parallelized in #1203) runs:

  • rust-build-host — 1 agent, host .so
  • rust-build-android — 4 parallel agents (matrix: arm, arm64, x86, x86_64), each via .buildkite/build-android-rust-target.sh

.buildkite/build-android-rust-target.sh installs Rust on each agent with:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -v -y

No rust-toolchain.toml exists at the repo root, so each agent independently resolves whatever stable channel rustup considers current at the moment its job runs. Across 5 parallel agents on different machines, with no shared cache, this introduces drift.

3. Why specifically armeabi-v7a

The crash hits 32-bit ARM almost exclusively. arm64-v8a devices do not crash. The pattern points to target-pointer-width-sensitive divergence:

  • usize/isize are 32-bit on armeabi-v7a and i686, 64-bit elsewhere.
  • The host (Linux x86_64) and arm64-v8a both have 64-bit usize, so their UniFFI checksums tend to match.
  • If any function in the UniFFI interface includes usize (directly or via a transitive type), the proc-macro emits a checksum that differs between 32-bit and 64-bit targets.
  • wp_api/src/ has usize in 4 files: themes.rs, wp_com/domains.rs, login/url_discovery.rs, wp_com/sites.rs. Worth auditing each to see whether usize appears in any #[uniffi::export] or #[derive(uniffi::Record)] surface (directly or via a public re-exported type).
  • Android x86 (also 32-bit) would in theory be affected too but doesn't show up in Sentry because Android x86 devices are essentially extinct in the field.

4. No prior tracking issue

Searched this repo for checksum, armeabi, abi (issues + PRs); only #1199 is loosely related (XCFramework snapshots, not Android). PR #1203 "Speed up Android CI builds" is the most likely point of introduction since it split a previously-single-agent build across 5 agents.

Suggested fixes

Two layers — both worth landing:

A. Generate Kotlin bindings from an Android .so (root cause)

Change nativeLibraryPath in native/kotlin/build.gradle.kts to point at one of the cross-built Android .so outputs (arm64-v8a is the safe choice — matches host pointer width, supermajority of users). Update the dependency in native/kotlin/api/kotlin/build.gradle.kts (generateUniFFIBindings.dependsOn) and the artifact-download in .buildkite/publish-wp-api-kotlin.sh accordingly.

This alone shifts the reference target but doesn't make ABIs agree with each other — that's fix B.

B. Eliminate target-pointer-width sensitivity (defense in depth)

  1. Pin the Rust toolchain. Add rust-toolchain.toml at the repo root:

    [toolchain]
    channel = "1.83.0"   # or current team standard
    targets = [
      "aarch64-linux-android",
      "armv7-linux-androideabi",
      "i686-linux-android",
      "x86_64-linux-android",
    ]

    rustup honors this automatically, so all 5 Buildkite agents resolve to the same version regardless of when their job runs.

  2. Audit usize/isize in the UniFFI surface. Anywhere usize/isize appears in a #[uniffi::export] function, #[derive(uniffi::Record)] field, or public type used through FFI, replace with explicit u32/u64. Start with the 4 files identified above.

C. CI gate (recommended)

Add a publish-time check that loads each per-ABI .so and compares its uniffi_checksum_* exports against the values embedded in the published Kotlin bindings. Fail the publish step on any mismatch.

Verification plan

  1. Locally reproduce the mismatch:
    • cargo build --release (host)
    • cargo build --release --target armv7-linux-androideabi (needs cargo-ndk)
    • nm -D target/release/libwp_mobile.so | grep uniffi_checksum > host.txt
    • nm -D target/armv7-linux-androideabi/release/libwp_mobile.so | grep uniffi_checksum > v7.txt
    • diff host.txt v7.txt — if non-empty, the hypothesis is confirmed.
  2. Apply fix A, regenerate bindings against the arm64-v8a .so, repeat the symbol diff between bindings-reference and armeabi-v7a.
  3. If still divergent, fix B (usize audit) is required, not optional.
  4. After upstream fix lands and a new alpha is published, the Android app bumps wordpress-rs, installs on a 32-bit ARM emulator or Galaxy A02 / Redmi 6 from device farm, verifies no crash.

Why the report on this project specifically

We've been tracking these two Sentry issues for weeks; the data is unambiguous about the affected ABI. Happy to provide additional Sentry breadcrumbs, raw event samples, or coordinate on a fix branch — let me know what would help.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions