Skip to content

fix(crashlytics,android): read the ELF build ID off the main thread, once per process - #18835

Merged
Lyokone merged 3 commits into
mainfrom
fix/crashlytics-android-build-id-off-main
Oct 22, 2026
Merged

Lyokone merged 3 commits into
mainfrom
fix/crashlytics-android-build-id-off-main

Conversation

@Lyokone

@Lyokone Lyokone commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Description

FlutterFirebaseCrashlyticsPlugin.onAttachedToEngine read the ELF build ID of libapp.so (ElfBuildIdReader.readBuildId) on the main thread, every time an engine attached. With minSdk >= 24 the native libraries are not extracted (extractNativeLibs=false is the AGP default), so nativeLibraryDir/libapp.so does not exist and the reader opens base.apk, then each split, as a ZipFile to look for lib/<abi>/libapp.so. That is disk I/O during app startup on the main thread, a StrictMode violation, and it is repeated for every background engine (messaging, workmanager, ...).

Reproduced on the Pixel 9a emulator with the crashlytics example (debug build, minSdk 24) and a local, uncommitted StrictMode.ThreadPolicy (detectDiskReads().penaltyLog()) in MainActivity.onCreate:

StrictMode disk-read violations on the main thread at launch
main 10, every one from FlutterFirebaseCrashlyticsPlugin.onAttachedToEngine -> ElfBuildIdReader.readBuildId -> readBuildIdFromApk -> ZipFile (access, open, fstat, lseek, read, mmap of the central directory)
this PR 0

Now:

  • onAttachedToEngine starts the read on cachedThreadPool and returns. Reading at startup rather than in the first recordError keeps it out of the moment the app is failing, which is often when it is short of memory or file descriptors.
  • ElfBuildIdReader.cachedBuildId reads libapp.so once per process and shares the result between engines: the file cannot change while the process runs.
  • A read that throws (I/O error, OutOfMemoryError) is not cached, so a later recordError retries it. A library without a build ID, such as a debug (JIT) build with no libapp.so, is cached, so the APK is not scanned again for every report.
  • recordError, which already runs on cachedThreadPool, still prefers the ELF build ID over the Dart snapshot one, as before.

The executor used for the startup read is an internal property, so tests can replace it without adding to the plugin's public API.

Tests

JUnit, run by android-unit-tests (:firebase_crashlytics:testDebugUnitTest), new FlutterFirebaseCrashlyticsPluginTest (Kotlin, src/test/kotlin):

  • onAttachedToEngine_readsLibappOnceInTheBackground: attaches the plugin to two engines with a context that records which threads look up applicationInfo (where every read starts). Checks that no read happens on the attaching thread, that libapp.so is read once for both engines, and that the cached ID is the library's. Fails on main (read on the attaching thread, once per engine).
  • cachedBuildId_readsLibappOncePerProcess: three lookups from two engines read libapp.so once and return its build ID. Fails if the cache is removed (3 reads).
  • cachedBuildId_readsAgainAfterAFailedRead: a first read that throws returns null, and the next one returns the build ID. Fails if failures are cached.
  • cachedBuildId_keepsALibraryWithoutBuildId: an APK without libapp.so is scanned once, not on every lookup.

The existing ElfBuildIdReaderTest helpers that build an ELF image are now package-private so the new test can reuse them.

Related Issues

None found.

Checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • My PR includes unit or integration tests for all changed/updated/fixed behaviors (See Contributor Guide).
  • All existing and new tests are passing.
  • I updated/added relevant documentation (doc comments with ///).
  • The analyzer (melos run analyze) does not report any problems on my PR.
  • I read and followed the Flutter Style Guide.
  • I signed the CLA.
  • I am willing to follow-up on review comments in a timely manner.

Breaking Change

  • No, this is not a breaking change.

…once per process

onAttachedToEngine read libapp.so's build ID on the main thread for every
engine. With minSdk >= 24 the native libraries are not extracted, so that
opened base.apk and its splits as zip files during startup, which is jank
and a StrictMode disk-read violation, repeated for each background engine.

Read it on first use instead, in recordError, which already runs on the
cached thread pool, and share the result across engines.
@gemini-code-assist

Copy link
Copy Markdown
Contributor
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Reading it on the first `recordError` meant the read could happen while the
app is short of memory or file descriptors, often the reason it reports an
error, and a failed read was cached for the whole process. It is now read once
when the plugin attaches, on the thread pool, so the first report doesn't
wait for it either.
@Lyokone
Lyokone marked this pull request as ready for review October 22, 2026 08:31

@demolaf demolaf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you update the description to match the second commit? It still says the build ID is read "on first use" in recordError and names onAttachedToEngine_doesNotReadLibappOnTheMainThread, but the code now reads it at startup in the background and the test is onAttachedToEngine_readsLibappOnceInTheBackground.

… seam internal

- A read of libapp.so that fails (memory or file descriptor pressure) is no
  longer cached, so a later report retries it. A library without a build ID
  (debug builds) is still read only once.
- `buildIdExecutor` is internal instead of a public `setBuildIdExecutor`, and
  the plugin test moves to Kotlin to use it.
@Lyokone

Lyokone commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

@demolaf Thanks for the review. I updated the description to match the current code: the build ID is read once at startup on cachedThreadPool, and the tests are onAttachedToEngine_readsLibappOnceInTheBackground plus the two new cachedBuildId tests from 60f558e. Both inline comments are addressed in that commit.

@Lyokone
Lyokone merged commit 37c3620 into main Oct 22, 2026
38 checks passed
@Lyokone
Lyokone deleted the fix/crashlytics-android-build-id-off-main branch October 22, 2026 11:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants