Skip to content

# [macOS] Native asset framework is mapped twice after a rebuild, because the manifest path does not match the install name #190799

Closed

Description

@pro100andrey

Summary

On macOS, flutter_tools stamps a native asset's framework with the install name @rpath/<name>.framework/<name>, but writes <name>.framework/<name> — without the @rpath/ prefix — into NativeAssetsManifest.json. That is the string the Dart VM passes to dlopen.

The two strings never match, so dyld cannot recognise the library it already has by name and falls back to identifying the file by (device, inode). That identity holds until the file is replaced — which is exactly what rebuilding the app does while a debug instance is running. The next @Native binding to resolve then loads a second copy of the library into the process.

Two copies of a C library do not share its global state. A handle produced by the first copy, passed into the second, dereferences an uninitialised pointer. For package:sqlite3 that is a hard SIGSEGV on the very first write after a rebuild — and it is not a development-only annoyance, because the operation that crashes is silently lost.

Reported downstream as simolus3/sqlite3.dart#381, where it looked like a SQLite binding problem. It is not: the same mechanism will hit any native asset whose bindings resolve lazily.

The mismatch, in the tool's own source

// packages/flutter_tools/lib/src/isolated/native_assets/macos/native_assets.dart:176
final newInstallName = '@rpath/$dylibFileName.framework/$dylibFileName';
// packages/flutter_tools/lib/src/isolated/native_assets/macos/native_assets_host.dart:287
return Uri(path: '$fileName.framework/$fileName');

The second is what ends up in the bundle:

{"native-assets":{"macos_arm64":{
  "package:sqlite3/src/ffi/libsqlite3.g.dart":["absolute","sqlite3.framework/sqlite3"]}}}

Note also that the path kind is recorded as absolute while the path is relative. That is a separate inconsistency and not the cause.

The sibling branch of the same file gets this right, and does not exhibit the bug:

// packages/flutter_tools/lib/src/isolated/native_assets/macos/native_assets.dart:237
final String newInstallName = dylibFile.path;   // install name == the path used

The standalone Dart VM writes its JIT asset mapping the same way — install name equal to the absolute path it dlopens — which is why dart run never reproduces this and only Flutter macOS builds do.

Steps to reproduce

Any Flutter macOS app with a native asset whose bindings are not all used at startup. package:sqlite3 is a convenient one, because a typical app only reads until the user acts, so the write-path bindings resolve late.

  1. flutter run -d macos and let the app open a database (reads only).
  2. Edit any source file the app's build depends on.
  3. Rebuild the same bundle while that instance keeps running (flutter drive, a second flutter run, or an IDE build).
  4. In the running window, perform the first operation that binds a text parameter — i.e. the first write.

The process dies with:

Exception Type:  EXC_BAD_ACCESS (SIGSEGV)
Exception Codes: KERN_INVALID_ADDRESS at 0x0000000000000000

Thread 0 Crashed:  com.apple.main-thread
0   ???                             0x0    ???
1   sqlite3                         ...    vdbeUnbind + 64
2   sqlite3                         ...    bindText + 52

and its usedImages list contains the asset twice — same UUID, same size, two base addresses, while every other image appears once:

0F642EA9-9A09-3DD0-BE88-C97BF2540F67  0x12f2ac000  .../tm_console.app/Contents/Frameworks/sqlite3.framework/Versions/A/sqlite3
0F642EA9-9A09-3DD0-BE88-C97BF2540F67  0x1353ac000  .../tm_console.app/Contents/Frameworks/sqlite3.framework/Versions/A/sqlite3

Under lldb with a breakpoint on dlopen, the last call before the crash is the second load of the asset:

(char *) $1 = "sqlite3.framework/sqlite3"
* stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
    frame #1: sqlite3`vdbeUnbind + 64
    frame #2: sqlite3`bindText + 52

Minimal reproduction of the dyld behaviour alone

No Flutter, no native assets, ~40 lines: two dylibs identical in every byte except LC_ID_DYLIB, each opened, replaced on disk (same bytes, new inode) and opened again.

── install name == the path it is opened by
   first open   1 mapping(s)
   after replacing the file and opening again: 1 mapping(s)

── install name is @rpath/…, as a framework in an app bundle has
   first open   1 mapping(s)
   after replacing the file and opening again: 2 mapping(s)   ← mapped TWICE

A control that opens the same path twice without replacing the file returns one mapping, so the replacement is the variable and not the counting.

Expected vs actual

Expected: rebuilding an app while a debug instance runs does not change what that instance has loaded.

Actual: the next lazily-resolved @Native binding maps a second copy of the asset, and any handle created by the first copy becomes unusable in the second.

Why it surfaces so late and looks so random

@Native bindings resolve one function at a time, on first use, and each resolution asks the embedder to load the asset again (runtime/lib/ffi_dynamic_library.cc, FfiResolveAsset → LoadAssetLibrary; no handle is cached). While the file is untouched, dlopen returns the image already in the process and nothing is wrong. So:

  • a window that only reads survives for as long as nobody rebuilds;
  • the crash lands on the first write, minutes after launch, which looks like "the Accept button is broken";
  • restarting the app fixes it, because a fresh process maps the current file once;
  • it never reproduces on a test bench, because nothing rebuilds mid-run;
  • it only bites developers, which is why it reads as flakiness.

Proposed fix

Make the manifest carry the same string the install name uses:

 // packages/flutter_tools/lib/src/isolated/native_assets/macos/native_assets_host.dart:287
-  return Uri(path: '$fileName.framework/$fileName');
+  return Uri(path: '@rpath/$fileName.framework/$fileName');

@rpath/ resolves fine through dlopen from inside the bundle — verified, see below.

packages/flutter_tools/lib/src/isolated/native_assets/ios/native_assets.dart calls the same helper and looks like it has the same mismatch; not verified here, as this was measured on macOS only.

Verification

Both directions were applied to an already-built bundle and driven through the reproduction above. The app was launched with flutter run --use-application-binary=` so the rebuild in step 3 could not undo the patch.

bundle under test window asset images operation new crash report
unmodified crashed 3/3 2 lost yes ×3
install name → sqlite3.framework/sqlite3 survived 2/2 1 committed none
manifest → @rpath/sqlite3.framework/sqlite3 survived 2/2 1 committed none

What is not the fix

  • The package. Resolving all 92 of package:sqlite3's @Native bindings up front with Native.addressOf was tried and does not prevent the crash, although it does run. Taking a symbol's address appears not to bind the JIT call site, so the late resolution still happens. Only actually executing the call site before the rebuild avoids it, which a library cannot arrange for arbitrary functions.
  • Worth knowing if anyone attempts that workaround: resolving every binding in bulk throws on symbols that a given SQLite build omits — e.g. sqlite3_column_table_name without SQLITE_ENABLE_COLUMN_METADATA. Lazy resolution hides their absence, because nothing ever looks them up.

Also worth considering (separate from this issue)

FfiResolveAsset calls into the embedder's loader on every symbol resolution and keeps no handle. That is safe only while dlopen is idempotent, which is an assumption about the filesystem rather than about the code. Caching the handle per asset in the isolate group would make this class of failure impossible regardless of install names or platform. Filed here for visibility; not required to fix the bug above.

Environment

Flutter 3.44.6 • channel stable
Framework • revision ee80f08bbf • 2026-07-08
Engine   • hash d3a3293399556a85388faf8c6f0723a7a5597aa8
Tools    • Dart 3.12.2

macOS 26.5.2 (25F84), arm64
package:sqlite3 3.5.1 (precompiled binary from the package's build hook)

Activity

  1. suryaKommana2662 commented on Aug 10, 2026

    @suryaKommana2662
    Contributor

    @pro100andrey Thank you for the detailed report. I noticed that the issue was tested with Flutter 3.44.6. Could you please verify if the issue is still reproducible on the latest stable Flutter release? Also, could you provide a minimal reproducible main.dart file that demonstrates the issue? This would help us reproduce and investigate the issue locally and route it to the appropriate team.

  2. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    and removed
    in triagePresently being triaged by the triage team
    on Aug 10, 2026
  3. pro100andrey commented on Aug 10, 2026

    @pro100andrey
    Author

    @suryaKommana2662 good day!

    flutter-190799-repro.zip

    Both, below. Short answer: yes — unchanged on the current stable (3.44.9), and the two lines are still identical on master.

    1. Still reproducible on the latest stable

    Flutter 3.44.9 • channel stable • revision 6b182d2c75 • 2026-08-05
    Engine   • b9499e4c25212536ba3a4eec4f5c1905fb3214fe
    Tools    • Dart 3.12.2
    
    macOS 26.5.2, arm64
    package:sqlite3 3.5.1
    

    Reproduced from a fresh flutter create --platforms=macos, one dependency, nothing else.

    The mismatch itself needs no app at all — two commands on any built bundle:

    $ otool -D build/macos/Build/Products/Debug/<app>.app/Contents/Frameworks/sqlite3.framework/Versions/A/sqlite3
    @rpath/sqlite3.framework/sqlite3
    
    $ cat .../App.framework/Versions/A/Resources/flutter_assets/NativeAssetsManifest.json
    {"format-version":[1,0,0],"native-assets":{"macos_arm64":{
      "package:sqlite3/src/ffi/libsqlite3.g.dart":["absolute","sqlite3.framework/sqlite3"]}}}

    The two lines that produce those strings are unchanged on both channels as of today:

    install name manifest
    stable 6b182d2c75 native_assets.dart#L173 native_assets_host.dart#L284
    master 1cd7c6b603 native_assets.dart#L176 native_assets_host.dart#L287

    2. Minimal reproduction

    pubspec.yaml — the generated one plus a single line:

    dependencies:
      flutter:
        sdk: flutter
      sqlite3: ^3.5.1

    lib/main.dart — three buttons and a mapping counter. The counter asks dyld directly, so the double mapping is observed rather than inferred from the crash:

    import 'dart:ffi';
    
    import 'package:flutter/material.dart';
    import 'package:sqlite3/sqlite3.dart';
    
    final Database db = sqlite3.openInMemory();
    
    /// Reads only. Every binding this needs was resolved at startup, so it keeps
    /// working after a rebuild.
    String read() {
      final ResultSet rows = db.select('SELECT count(*) AS n FROM t');
      return 'read ${rows.first['n']} row(s)';
    }
    
    /// Resolves bindings that startup did not need, and does nothing else.
    /// `sqlite3_libversion` and `sqlite3_sourceid` return compile-time constants
    /// and touch neither the database handle nor any global, so this survives
    /// being called in a second copy of the library — which is what makes the
    /// second copy observable instead of merely fatal.
    String probeUnusedBinding() {
      return 'sqlite3_libversion() = ${sqlite3.version.libVersion}';
    }
    
    /// The first write. Resolving `sqlite3_bind_text` in the second copy of the
    /// library and handing it a statement made by the first is the crash.
    String write() {
      db.prepare('INSERT INTO t (v) VALUES (?)').execute(<Object?>['hello']);
      return 'inserted';
    }
    
    /// How many times the sqlite3 native asset is mapped into this process, asked
    /// of dyld directly. Every image in a healthy process is mapped once.
    int mappedCopies() {
      final DynamicLibrary process = DynamicLibrary.process();
      final int Function() imageCount =
          process.lookupFunction<Uint32 Function(), int Function()>(
        '_dyld_image_count',
      );
      final Pointer<Uint8> Function(int) imageName = process.lookupFunction<
          Pointer<Uint8> Function(Uint32),
          Pointer<Uint8> Function(int)>('_dyld_get_image_name');
    
      var copies = 0;
      for (var i = 0; i < imageCount(); i++) {
        if (_cString(imageName(i)).contains('sqlite3.framework')) {
          copies++;
        }
      }
      return copies;
    }
    
    String _cString(Pointer<Uint8> pointer) {
      var length = 0;
      while (pointer[length] != 0) {
        length++;
      }
      return String.fromCharCodes(pointer.asTypedList(length));
    }
    
    void main() {
      // Opening a database and creating a table binds no text parameters, so the
      // write path stays unresolved — which is what a typical app looks like
      // while the user is still only reading.
      db.execute('CREATE TABLE t (v TEXT)');
      runApp(const ReproApp());
    }
    
    class ReproApp extends StatefulWidget {
      const ReproApp({super.key});
    
      @override
      State<ReproApp> createState() => _ReproAppState();
    }
    
    class _ReproAppState extends State<ReproApp> {
      final List<String> _log = <String>['opened — asset mapped ${mappedCopies()}x'];
    
      void _run(String Function() action) {
        final String line = '${action()} — asset mapped ${mappedCopies()}x';
        debugPrint(line);
        setState(() => _log.add(line));
      }
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          home: Scaffold(
            body: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Wrap(
                    spacing: 8,
                    children: <Widget>[
                      FilledButton(
                        onPressed: () => _run(read),
                        child: const Text('read'),
                      ),
                      FilledButton(
                        onPressed: () => _run(probeUnusedBinding),
                        child: const Text('probe an unused binding'),
                      ),
                      FilledButton(
                        onPressed: () => _run(write),
                        child: const Text('write (binds text)'),
                      ),
                    ],
                  ),
                  const SizedBox(height: 16),
                  Expanded(
                    child: ListView(
                      children: <Widget>[
                        for (final String line in _log)
                          Text(line, style: const TextStyle(fontFamily: 'Menlo')),
                      ],
                    ),
                  ),
                ],
              ),
            ),
          ),
        );
      }
    }

    Steps

    1. flutter run -d macos
    2. press read — one mapping, everything normal
    3. from a second terminal, in the same project, rebuild the bundle the running
      instance was launched from:
      $ touch lib/main.dart && flutter build macos --debug
      This replaces the framework binary with an identical one at a new inode
      (295524898 → 295525216 in my run; same size, and the two mappings in the
      crash report below carry the same LC_UUID, so the bytes really are the
      same). Nothing about the asset changes — the file being replaced is the
      whole variable.
    4. press probe an unused binding — still returns normally, and the counter
      now says 2
    5. press write (binds text) — SIGSEGV

    What it printed

    ── before the rebuild
       mappedCopies()       = 1
       read()               = read 0 row(s)
    
    ── rebuild: framework binary replaced, inode 295524898 → 295525216
    
    ── after the rebuild
       read()               = read 0 row(s)     reads still fine — nothing new resolved
       mappedCopies()       = 1
       probeUnusedBinding() = 3.53.4            first resolution since the rebuild
       mappedCopies()       = 2                 ← the asset is now mapped twice
       write()              → process gone
    
    Exception Type:  EXC_BAD_ACCESS (SIGSEGV)
    Exception Codes: KERN_INVALID_ADDRESS at 0x0000000000000000
    
    0   ???        0x0
    1   sqlite3    vdbeUnbind
    2   sqlite3    bindText
    
    usedImages (same UUID, same size, two base addresses):
      1dd81806-791a-3992-a257-0fc74425f9bf  0x125b2c000  1556480  …/sqlite3.framework/Versions/A/sqlite3
      1dd81806-791a-3992-a257-0fc74425f9bf  0x12802c000  1556480  …/sqlite3.framework/Versions/A/sqlite3
    

    (I drove read() / probeUnusedBinding() / write() over the VM service instead of
    clicking, so the run could be scripted. Those are exactly the functions the three
    buttons call.)

    3. The one-line fix, re-verified on 3.44.9

    Patching only the manifest string in an already-built bundle, then launching it with
    --use-application-binary=… so that step 3 could not undo the patch:

    -  "package:sqlite3/src/ffi/libsqlite3.g.dart":["absolute","sqlite3.framework/sqlite3"]
    +  "package:sqlite3/src/ffi/libsqlite3.g.dart":["absolute","@rpath/sqlite3.framework/sqlite3"]
    ── after the same rebuild
       read()               = read 0 row(s)
       probeUnusedBinding() = 3.53.4
       mappedCopies()       = 1                 ← still one
       write()              = inserted
       read()               = read 1 row(s)     the row is really there
    

    4. One detail worth adding

    It is not specific to the write path. In my first pass the probe button called
    sqlite3_last_insert_rowid — a read-only call that touches nothing but the handle —
    and that crashed, identically, because it happened to be the first binding resolved
    after the rebuild:

    0   ???        0x0
    1   sqlite3    sqlite3_last_insert_rowid
    

    Frame 0 is address 0x0 in both reports, which is consistent with the second copy
    never having run sqlite3_initialize, leaving the mutex vtable it calls through
    null. So any binding that dereferences library state will do; the write path is
    merely what a real app reaches first, and sqlite3_libversion survives only because
    it returns a compile-time constant — which is what makes it usable as the probe
    above.

    Thanks!

  4. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Aug 10, 2026
  5. suryaKommana2662 commented on Aug 10, 2026

    @suryaKommana2662
    Contributor

    @pro100andrey Thanks for providing the complete reproduction app and detailed analysis on latest stable. Routing it to team-macos for further investigation and review of the linked PR (#190819).

  6. added
    toolAffects the "flutter" command-line tool. See also t: labels.
    platform-macosBuilding on or for macOS specifically
    fyi-toolFor the attention of Flutter Tool team
    team-macosOwned by the macOS platform team
    and removed
    in triagePresently being triaged by the triage team
    on Aug 10, 2026
  7. cbracken commented on Aug 13, 2026

    @cbracken
    Member

    /cc @dcharkes are you the person most familiar with this?

    (macOS triage meeting)

  8. added
    P2Important issues not at the top of the work list
    on Aug 13, 2026
  9. dcharkes commented on Aug 13, 2026

    @dcharkes
    Contributor

    /cc @dcharkes are you the person most familiar with this?

    (macOS triage meeting)

    Yep, reviewing the PR. Thanks for the ping! 🙏

  10. github-actions commented on Sep 1, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  11. locked as resolved and limited conversation to collaborators on Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work listfyi-toolFor the attention of Flutter Tool teamplatform-macosBuilding on or for macOS specificallyteam-macosOwned by the macOS platform teamtoolAffects the "flutter" command-line tool. See also t: labels.triaged-macosTriaged by the macOS platform team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions