Skip to content

fix(crashlytics,android): avoid heap-loading libapp.so for build ID - #18483

Open
SelaseKay wants to merge 5 commits into
mainfrom
crashlytics_18480
Open

fix(crashlytics,android): avoid heap-loading libapp.so for build ID#18483
SelaseKay wants to merge 5 commits into
mainfrom
crashlytics_18480

Conversation

@SelaseKay

Copy link
Copy Markdown
Contributor

Description

Fixes an Android firebase_crashlytics startup OOM when libapp.so is stored inside the APK/split APK.

Previously, ElfBuildIdReader loaded the entire libapp.so ZIP entry into a byte array to read the ELF build ID. Large Flutter native libraries could exceed the app heap limit and crash during plugin registration. This now streams the ZIP entry to a temp file with a fixed-size buffer and reuses the existing file-based ELF parser. Build ID extraction also fails gracefully instead of crashing startup.

Related Issues

Fixes #18480

Replace this paragraph with a list of issues related to this PR from the issue database. Indicate, which of these issues are resolved or fixed by this PR. Note that you'll have to prefix the issue numbers with flutter/flutter#.

Checklist

Before you create this PR confirm that it meets all requirements listed below by checking the relevant checkboxes ([x]).
This will ensure a smooth and quick review process. Updating the pubspec.yaml and changelogs is not required.

  • 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

Does your PR require plugin users to manually update their apps to accommodate your change?

  • Yes, this is a breaking change.
  • No, this is not a breaking change.

@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.

@jonasbernardo

Copy link
Copy Markdown

This fixes the OOM, but it more than doubles the time this plugin holds the main thread during startup — I built the branch and measured it on a device before merge.

Three release APKs of the same app, identical build config, atrace -a <pkg> on the FlutterEngineConnectionRegistry#add …FlutterFirebaseCrashlyticsPlugin slice. Flutter 3.44.8, mid-range Android 13 arm64, extractNativeLibs=false, 5 warm cold starts each, medians:

plugin slice plugin registration phase (31 plugins)
5.2.6 as published (byte[20 MB]) 16.1 ms 21.8 ms
this PR (temp file) 38.1 ms 48.7 ms
bounded 4 KB prefix (source below) 0.63 ms 10.0 ms

This is synchronous main-thread work inside onAttachedToEngine, before the first frame, paid once per FlutterEngine. The regression is the write becoming visible: dd on the same device puts a 20.4 MB write at ~24 ms after subtracting the ~25 ms per-invocation floor, and 16.1 + ~22 ≈ 38.

It also scales with the app. Our libapp.so is 20 MB; the app in #18480 has a 190,158,336-byte one, so it would stream ~181 MiB through cacheDir on the main thread on every attach.

To be straight about the limits of my data: end-to-end am start -W TotalTime medians were 479 ms for 5.2.6, 446 ms for this PR and 388 ms for the prefix variant. The first two are indistinguishable on this device — the same APK ranged from 430 to 750 ms across runs — so I am not claiming that the temp file regresses time-to-first-frame; only that the phase it touches unambiguously doubles, and that it is synchronous main-thread work. (The prefix variant was the one exception: lower than 5.2.6 in all five runs.)

One more thing about the temp file, on the failure path: if the process is killed between createTempFile and the finally — likely on the low-memory devices in that report — a full-size file stays in cacheDir. deleteOnExit() won't help, since it registers a JVM shutdown hook and Android processes are killed rather than exited.

A bounded prefix fixes the OOM and speeds startup up

It's the only one of the three that improves on the status quo: the slice drops from 16.1 ms to 0.63 ms and registration goes from 21.8 to 10.0 ms.

The idea is to make every allocation sized by a constant, never by a value read from the file, which removes the OOM at its source instead of relocating it to the filesystem:

private static final int INITIAL_PREFIX_BYTES = 4 * 1024;
private static final int MAX_PREFIX_BYTES = 256 * 1024;
private static final int MAX_DESC_BYTES = 1024;

Read that prefix from the entry, parse it in memory with bounds checks on every offset, and if a note ever sat beyond it, retry once with a capped larger prefix. descsz is capped too, so a corrupt note can't size an allocation either.

Note that skip() on the entry stream does not work here, which is why this reads a prefix: the existing note loop seeks backwards to the program header table whenever a PT_NOTE turns out not to carry the GNU note — two libraries in my own APK do exactly that.

I ran the parser offline against all 12 shared objects in my release APK (libapp.so, libflutter.so and the androidx ones, across three ABIs) and every build ID matches an independent implementation, including the ELF32 ones and the library with a non-GNU PT_NOTE first. One 4 KB read suffices for all of them — the highest byte the algorithm ever needs is 812. Truncated input reports "needs 456 bytes" instead of throwing; 512 bytes of garbage returns null.

It doesn't reach zero — new ZipFile(...) still parses a 1,461-entry central directory, which is most of the remaining 0.63 ms.

Full source below; this is exactly what produced the 0.63 ms row. Happy to open it as a PR with unit tests for the parser — the package has no src/test yet, and small ELF fixtures cover it well.

ElfBuildIdReader.java — full source (click to expand)
// Copyright 2024 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package io.flutter.plugins.firebase.crashlytics;

import android.content.Context;
import android.util.Log;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

/**
 * Reads the ELF build ID from libapp.so at runtime.
 *
 * <p>The Firebase CLI's {@code crashlytics:symbols:upload} command uses the ELF build ID (from the
 * {@code .note.gnu.build-id} section) when uploading symbols. To ensure Crashlytics can match crash
 * reports to uploaded symbols, the plugin must report the same ELF build ID rather than the Dart
 * VM's internal snapshot build ID (which may differ, especially for AAB + flavor builds).
 *
 * <p>Only a bounded prefix of the library is ever read into memory: every allocation here is sized
 * by a constant, never by a value read from the file.
 */
final class ElfBuildIdReader {

  private static final String TAG = "FLTFirebaseCrashlytics";

  private static final byte[] ELF_MAGIC = {0x7f, 'E', 'L', 'F'};
  private static final int ELFCLASS64 = 2;
  private static final int PT_NOTE = 4;
  private static final int NT_GNU_BUILD_ID = 3;
  private static final String GNU_NOTE_NAME = "GNU";

  /** First attempt: enough for the ELF header, the program header table and the notes. */
  private static final int INITIAL_PREFIX_BYTES = 4 * 1024;

  /** Hard ceiling for a retry, in case a note sits further in. */
  private static final int MAX_PREFIX_BYTES = 256 * 1024;

  /** A GNU build ID is 8-20 bytes; this bounds a hostile or corrupt descsz. */
  private static final int MAX_DESC_BYTES = 1024;

  private ElfBuildIdReader() {}

  /** Parse outcome: either a build ID, or how many bytes would have been needed. */
  private static final class ParseResult {
    final String buildId;
    final int bytesNeeded;

    ParseResult(String buildId, int bytesNeeded) {
      this.buildId = buildId;
      this.bytesNeeded = bytesNeeded;
    }

    static final ParseResult NOT_FOUND = new ParseResult(null, 0);
  }

  /**
   * Reads the ELF build ID from libapp.so.
   *
   * @return the build ID as a lowercase hex string, or {@code null} if it cannot be read.
   */
  static String readBuildId(Context context) {
    try {
      // Try extracted native library first.
      String nativeLibDir = context.getApplicationInfo().nativeLibraryDir;
      File libApp = new File(nativeLibDir, "libapp.so");
      if (libApp.exists()) {
        return readBuildIdFromFile(libApp);
      }

      // Fall back to reading from inside the APK (or split APKs for AAB installs).
      return readBuildIdFromApk(context);
    } catch (Exception | OutOfMemoryError e) {
      Log.d(TAG, "Could not read ELF build ID from libapp.so", e);
      return null;
    }
  }

  private static String readBuildIdFromApk(Context context) throws Exception {
    // Check the base APK first.
    String result = readBuildIdFromZip(context.getApplicationInfo().sourceDir);
    if (result != null) {
      return result;
    }

    // For AAB installs, libapp.so is in a split APK (e.g., split_config.arm64_v8a.apk).
    String[] splitDirs = context.getApplicationInfo().splitSourceDirs;
    if (splitDirs != null) {
      for (String splitDir : splitDirs) {
        result = readBuildIdFromZip(splitDir);
        if (result != null) {
          return result;
        }
      }
    }
    return null;
  }

  private static String readBuildIdFromZip(String apkPath) throws Exception {
    try (ZipFile zipFile = new ZipFile(apkPath)) {
      Enumeration<? extends ZipEntry> entries = zipFile.entries();
      while (entries.hasMoreElements()) {
        ZipEntry entry = entries.nextElement();
        if (entry.getName().endsWith("/libapp.so")) {
          int limit = INITIAL_PREFIX_BYTES;
          while (true) {
            byte[] prefix;
            try (InputStream is = zipFile.getInputStream(entry)) {
              prefix = readPrefix(is, limit);
            }
            ParseResult result = readBuildIdFromBytes(prefix);
            if (result.buildId != null) {
              return result.buildId;
            }
            if (result.bytesNeeded <= prefix.length || limit >= MAX_PREFIX_BYTES) {
              return null;
            }
            limit = (int) Math.min(MAX_PREFIX_BYTES, (long) result.bytesNeeded);
          }
        }
      }
    }
    return null;
  }

  private static String readBuildIdFromFile(File elfFile) throws Exception {
    int limit = INITIAL_PREFIX_BYTES;
    while (true) {
      byte[] prefix;
      try (InputStream is = new FileInputStream(elfFile)) {
        prefix = readPrefix(is, limit);
      }
      ParseResult result = readBuildIdFromBytes(prefix);
      if (result.buildId != null) {
        return result.buildId;
      }
      if (result.bytesNeeded <= prefix.length || limit >= MAX_PREFIX_BYTES) {
        return null;
      }
      limit = (int) Math.min(MAX_PREFIX_BYTES, (long) result.bytesNeeded);
    }
  }

  /** Reads at most {@code limit} bytes from the start of the stream. */
  private static byte[] readPrefix(InputStream is, int limit) throws IOException {
    byte[] buffer = new byte[limit];
    int offset = 0;
    while (offset < limit) {
      int read = is.read(buffer, offset, limit - offset);
      if (read < 0) break;
      offset += read;
    }
    return offset == limit ? buffer : Arrays.copyOf(buffer, offset);
  }

  /**
   * Parses an ELF prefix. Visible for testing.
   *
   * <p>Every bound is checked against the buffer length, so a truncated prefix reports how many
   * bytes it would have needed instead of throwing.
   */
  static ParseResult readBuildIdFromBytes(byte[] data) {
    if (data.length < 64) {
      return new ParseResult(null, 64);
    }
    for (int i = 0; i < 4; i++) {
      if (data[i] != ELF_MAGIC[i]) {
        return ParseResult.NOT_FOUND;
      }
    }

    boolean is64 = (data[4] & 0xFF) == ELFCLASS64;
    ByteOrder order = (data[5] & 0xFF) == 1 ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN;
    ByteBuffer buf = ByteBuffer.wrap(data).order(order);

    long phoff;
    int phentsize;
    int phnum;
    if (is64) {
      // e_phoff at 32, e_phentsize at 54, e_phnum at 56.
      phoff = buf.getLong(32);
      phentsize = buf.getShort(54) & 0xFFFF;
      phnum = buf.getShort(56) & 0xFFFF;
    } else {
      // e_phoff at 28, e_phentsize at 42, e_phnum at 44.
      phoff = buf.getInt(28) & 0xFFFFFFFFL;
      phentsize = buf.getShort(42) & 0xFFFF;
      phnum = buf.getShort(44) & 0xFFFF;
    }

    if (phoff <= 0 || phentsize <= 0 || phnum <= 0) {
      return ParseResult.NOT_FOUND;
    }

    long tableEnd = phoff + (long) phnum * phentsize;
    if (tableEnd > data.length) {
      return new ParseResult(null, clampToInt(tableEnd));
    }

    int bytesNeeded = 0;
    for (int i = 0; i < phnum; i++) {
      int phdr = (int) (phoff + (long) i * phentsize);
      if (buf.getInt(phdr) != PT_NOTE) {
        continue;
      }
      long noteOffset;
      long noteSize;
      if (is64) {
        // p_offset at phdr + 8, p_filesz at phdr + 32.
        noteOffset = buf.getLong(phdr + 8);
        noteSize = buf.getLong(phdr + 32);
      } else {
        // p_offset at phdr + 4, p_filesz at phdr + 16.
        noteOffset = buf.getInt(phdr + 4) & 0xFFFFFFFFL;
        noteSize = buf.getInt(phdr + 16) & 0xFFFFFFFFL;
      }
      if (noteOffset < 0 || noteSize <= 0) {
        continue;
      }
      long noteEnd = noteOffset + noteSize;
      if (noteEnd > data.length) {
        // Remember the cheapest retry that could still succeed.
        int needed = clampToInt(noteEnd);
        if (bytesNeeded == 0 || needed < bytesNeeded) {
          bytesNeeded = needed;
        }
        continue;
      }
      String buildId = findGnuBuildIdInBuffer(buf, (int) noteOffset, (int) noteSize);
      if (buildId != null) {
        return new ParseResult(buildId, 0);
      }
    }
    return bytesNeeded == 0 ? ParseResult.NOT_FOUND : new ParseResult(null, bytesNeeded);
  }

  private static int clampToInt(long value) {
    return (int) Math.min(value, (long) MAX_PREFIX_BYTES);
  }

  /**
   * Searches a PT_NOTE segment for the GNU build ID note.
   *
   * <p>Note format: namesz (4) | descsz (4) | type (4) | name (aligned to 4) | desc (aligned to 4)
   */
  private static String findGnuBuildIdInBuffer(ByteBuffer buf, int offset, int size) {
    int end = offset + size;
    int pos = offset;

    while (pos + 12 <= end) {
      int namesz = buf.getInt(pos);
      int descsz = buf.getInt(pos + 4);
      int type = buf.getInt(pos + 8);

      if (namesz < 0 || descsz < 0 || namesz > 256 || descsz > MAX_DESC_BYTES) {
        break;
      }

      int nameAligned = align4(namesz);
      int descPos = pos + 12 + nameAligned;

      if (namesz > 0 && type == NT_GNU_BUILD_ID && descPos + descsz <= end) {
        byte[] nameBytes = new byte[namesz];
        for (int i = 0; i < namesz; i++) {
          nameBytes[i] = buf.get(pos + 12 + i);
        }
        String name =
            new String(
                nameBytes, 0, Math.max(0, namesz - 1), java.nio.charset.StandardCharsets.US_ASCII);

        if (GNU_NOTE_NAME.equals(name) && descsz > 0) {
          byte[] desc = new byte[descsz];
          for (int i = 0; i < descsz; i++) {
            desc[i] = buf.get(descPos + i);
          }
          return bytesToHex(desc);
        }
      }

      pos = descPos + align4(descsz);
    }
    return null;
  }

  private static int align4(int value) {
    return (value + 3) & ~3;
  }

  private static String bytesToHex(byte[] bytes) {
    StringBuilder sb = new StringBuilder(bytes.length * 2);
    for (byte b : bytes) {
      sb.append(String.format("%02x", b & 0xff));
    }
    return sb.toString();
  }
}

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.

firebase_crashlytics || firebaseCrashlyticsPluginVersion : Fatal Exception: java.lang.OutOfMemoryError

2 participants