Skip to content

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

@jointhejourney

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!

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