Skip to content

Pigeon's ProxyApi causes crash on iOS when multiple instances are created in parallel #168531

Closed
flutter/packages
#9231

Description

@jointhejourney

Steps to reproduce

Hi, I am dealing with a recurring crash with Pigeon's new Proxy APIs when creating multiple instances of an object that takes another PigeonApi object in its constructor.

I've been able to create a reproducible example app here: https://github.com/jointhejourney/flutter-proxy-api-crash

The reproducible example will have a button that you can trigger the crash. it will create a bunch of proxy API instances in paralell, and after some time (usually 5~10s) the app will crash.

Note: I can consistently reproduce this on my iPhone 15 Pro. If the app doesn't crash for you, just increase the parallelSimulationsCount count from 500 to 2000.

Here's how to reproduce the crash:

Using Pigeon's new @ProxyApi(), set up the following apis:

@ProxyApi(swiftOptions: SwiftProxyApiOptions(import: 'Foundation'))
abstract class NSAttributedString extends NSObject {
  NSAttributedString({String? string});
}

@ProxyApi(swiftOptions: SwiftProxyApiOptions(import: 'Foundation'))
abstract class NSMutableAttributedString extends NSAttributedString {
  NSMutableAttributedString(NSAttributedString attributedString);
}

@ProxyApi()
abstract class NSAttributedStringGenerator extends NSObject {
  @static
  late final NSAttributedStringGenerator instance;

  NSAttributedString? generate();
}

On the native iOS side, just set up simple delegates for it:

class NSAttributedStringGenerator {
  func generate() -> NSAttributedString {
    return NSAttributedString(string: "Hello, World!")
  }
}

class ProxiesApiDelegate: ProxiesPigeonProxyApiDelegate {
  func pigeonApiNSAttributedString(_ registrar: ProxiesPigeonProxyApiRegistrar)
    -> PigeonApiNSAttributedString
  {
    return PigeonApiNSAttributedString(
      pigeonRegistrar: registrar, delegate: NSAttributedStringProxyApiDelegate())
  }

  func pigeonApiNSMutableAttributedString(_ registrar: ProxiesPigeonProxyApiRegistrar)
    -> PigeonApiNSMutableAttributedString
  {
    return PigeonApiNSMutableAttributedString(
      pigeonRegistrar: registrar, delegate: NSMutableAttributedStringProxyApiDelegate())
  }

  func pigeonApiNSAttributedStringGenerator(_ registrar: ProxiesPigeonProxyApiRegistrar)
    -> PigeonApiNSAttributedStringGenerator
  {
    return PigeonApiNSAttributedStringGenerator(
      pigeonRegistrar: registrar, delegate: NSAttributedStringGeneratorProxyApiDelegate())
  }
}

class NSAttributedStringProxyApiDelegate: PigeonApiDelegateNSAttributedString {
  func pigeonDefaultConstructor(pigeonApi: PigeonApiNSAttributedString, string: String?) throws
    -> NSAttributedString
  {
    return NSAttributedString(string: string ?? "")
  }
}

class NSMutableAttributedStringProxyApiDelegate: PigeonApiDelegateNSMutableAttributedString {
  func pigeonDefaultConstructor(
    pigeonApi: PigeonApiNSMutableAttributedString, attributedString: NSAttributedString
  ) throws -> NSMutableAttributedString {
    return NSMutableAttributedString(attributedString: attributedString)
  }
}

class NSAttributedStringGeneratorProxyApiDelegate: PigeonApiDelegateNSAttributedStringGenerator {
  func instance(pigeonApi: PigeonApiNSAttributedStringGenerator) throws
    -> NSAttributedStringGenerator
  {
    return NSAttributedStringGenerator()
  }

  func generate(
    pigeonApi: PigeonApiNSAttributedStringGenerator, pigeonInstance: NSAttributedStringGenerator
  ) throws -> NSAttributedString? {
    return pigeonInstance.generate()
  }
}

Finally, simulate the crash by creating hundreds of instances in parallel. After a few seconds, it will crash.

Future<void> simulateCrash() async {
  final generator = await NSAttributedStringGenerator.instance.generate();

  if (generator == null) {
    throw Exception('Generator is null');
  }

  final attributedString = NSMutableAttributedString(
    attributedString: generator,
  );

  print('attributedString: $attributedString');
}

void runCrashLoop() async {
  final parallelSimulations = List.generate(
    500,
    (_) => simulateCrash(),
  );
  await Future.wait(parallelSimulations);

  // Schedule the next iteration without blocking the main thread
  Future.microtask(_runCrashLoop);
}


runCrashLoop();

Expected results

It should not crash.

Actual results

Crash with:

Failed to find instance with identifier: 74050
Could not cast value of type 'NSNull' (0x1f176a748) to 'NSAttributedString' (0x1f176a358).
warning: could not execute support code to read Objective-C class data in the process. This may reduce the quality of type information available.

Thread 1: EXC_BREAKPOINT (code=1, subcode=0x18dc45380)
An abort signal terminated the process. Such crashes often happen because of an uncaught exception or unrecoverable error or calling the abort() function.

The crash occurs on the dev.flutter.pigeon.pigeon_crash_example.NSMutableAttributedString.pigeon_defaultConstructor channel, during this casting:

let attributedStringArg = args[1] as! NSAttributedString

I'll confess I've lost a few nights troubleshooting this, but hopefully all the info above can help identify a fix. 😅

I've noticed that the WeakReference's finalizer for the NSAttributedString returned by the .generate() function call is getting called shortly after the addHostCreatedInstance() -> _addInstanceWithIdentifier() functions are called.

So by the time the code for the constructor of NSMutableAttributedString() is called, the strong reference in the native side doesn't exist anymore, hence the "Failed to find instance with identifier" error and subsequent crash.

Interestingly, if you run the code above sequentially, I am unable to reproduce the crash. I was only able to find this bug because our app can use quite a bit of memory, and Dart is triggering the garbage collector between these calls... something like this:

Future<void> simulateCrash() async {
  // Native send the NSAttributedString to Dart during it's write() codec operations
  // Dart adds the WeakReference to the weak references array
  // Dart immediately garbage collects the WeakReference (finalizer is getting called)
  // Dart notifies native side to remove the strong reference to the NSAttributedString object
  // Native removes the reference
  // However, Dart has kept the instance ID in the _identifiers expando
  final generator = await NSAttributedStringGenerator.instance.generate();

  // Native finally responds with the NSAttributedString object, but now it's not stored in Pigeon's instance manager any longer
  // generator will not be null because the native side sends the actual object as a result of the generate() call, not the identifier
  if (generator == null) {
    throw Exception('Generator is null');
  }

  // Dart notifies native side a new NSMutableAttributedString instance was created, with the NSMutableAttributedString object as the first arg and the ID of the NSAttributedString instance that is still in the _identifiers Expando
  // Native tries to find the NSAttributedString object in it's strong references, doesn't find it, and tries to cast NSNull to NSAttributedString which causes the crash
  final attributedString = NSMutableAttributedString(
    attributedString: generator,
  );
}

Code sample

Code sample

Please see the reproducible app here: https://github.com/jointhejourney/flutter-proxy-api-crash

Logs

Logs
Failed to find instance with identifier: 74050
Could not cast value of type 'NSNull' (0x1f176a748) to 'NSAttributedString' (0x1f176a358).
warning: could not execute support code to read Objective-C class data in the process. This may reduce the quality of type information available.
Thread 1: EXC_BREAKPOINT (code=1, subcode=0x18dc45380)
An abort signal terminated the process. Such crashes often happen because of an uncaught exception or unrecoverable error or calling the abort() function.

Flutter Doctor output

Doctor output
[✓] Flutter (Channel stable, 3.29.1, on macOS 15.1 24B83 darwin-arm64, locale en-US) [314ms]
    • Flutter version 3.29.1 on channel stable at /Users/gabriel/fvm/versions/3.29.1
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 09de023485 (10 weeks ago), 2025-02-28 13:44:05 -0800
    • Engine revision 871f65ac1b
    • Dart version 3.7.0
    • DevTools version 2.42.2

[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0) [2.1s]
    • Android SDK at /Users/gabriel/Library/Android/sdk
    • Platform android-35, build-tools 35.0.0
    • ANDROID_HOME = /Users/gabriel/Library/Android/sdk
    • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
      This is the JDK bundled with the latest Android Studio installation on this machine.
      To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
    • Java version OpenJDK Runtime Environment (build 21.0.3+-79915917-b509.11)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 16.1) [600ms]
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 16B40
    • CocoaPods version 1.16.2

[✓] Chrome - develop for the web [11ms]
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2024.2) [11ms]
    • Android Studio at /Applications/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 21.0.3+-79915917-b509.11)

[✓] VS Code (version 1.99.0) [10ms]
    • VS Code at /Applications/Visual Studio Code.app/Contents
    • Flutter extension version 3.108.0

[✓] Connected device (4 available) [7.0s]
    • iPhone (mobile)                 • 0000 • ios            • iOS 18.4.1 22E252

[✓] Network resources [157ms]
    • All expected network resources are available.

• No issues found!

Activity

  1. stuartmorgan-g commented on May 8, 2025

    @stuartmorgan-g
    Contributor
  2. self-assigned this
    on May 9, 2025
  3. bparrishMines commented on May 9, 2025

    @bparrishMines
    Contributor

    @jointhejourney Thanks for investigating this issue! I was able to reproduce the bug locally. I believe that you are correct that the cause is related to the WeakReference finalizer running soon after being created. From my own investigation it looks like the steps are:

    1. Dart makes a host api call that returns a ProxyApi generated class: https://github.com/jointhejourney/flutter-proxy-api-crash/blob/main/lib/main.dart#L79
    2. The Swift implementation returns a new instance from a host api method: https://github.com/jointhejourney/flutter-proxy-api-crash/blob/main/ios/Runner/AppDelegate.swift#L58
    3. While returning this instance, the Swift codec makes a call to create a new Dart instance to represent it: https://github.com/jointhejourney/flutter-proxy-api-crash/blob/main/ios/Runner/Proxies.swift#L480
    4. The Dart generated code creates a new instance and adds it to the InstanceManager: https://github.com/jointhejourney/flutter-proxy-api-crash/blob/main/lib/src/proxies.g.dart#L564
    5. The InstanceManager stores the new instance as a weak reference and a copy as a strong instance: https://github.com/jointhejourney/flutter-proxy-api-crash/blob/main/lib/src/proxies.g.dart#L234
    6. The Dart host api call eventually returns the weak referenced instance created: https://github.com/jointhejourney/flutter-proxy-api-crash/blob/main/lib/src/proxies.g.dart#L829

    It seems there is small timeframe between 3 and 6 where the weak referenced instance is waiting to be returned to the user and can be garbage collected. This most likely only happens when Dart is under memory pressure and has to clean up instances immediately.

  4. jointhejourney commented on May 10, 2025

    @jointhejourney
    Author

    That's great to hear @bparrishMines, I'll give it a try with your PR and let you know if I see any issues. By the way, amazing work on the Proxy APIs! You have no idea how much this has improved my developer experience with Flutter. 😃

  5. github-actions commented on May 29, 2025

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

  6. locked as resolved and limited conversation to collaborators on May 29, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions