Skip to content

[pigeon] Allow event channel unregistration - #13438

Merged
auto-submit[bot] merged 8 commits into
flutter:mainfrom
stuartmorgan:pigeon-event-channel-unregistration
Oct 30, 2026
Merged

auto-submit[bot] merged 8 commits into
flutter:mainfrom
stuartmorgan:pigeon-event-channel-unregistration

Conversation

@stuartmorgan

@stuartmorgan stuartmorgan commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Adds a new class for each event channel API that wraps the underlying EventChannel and allows setting the stream handler, matching the underlying Flutter API. This allows clients that need to set a new handler later (notably, removing a handler to avoid an engine-lifetime reference to a handler for a transient channel) to do so, using the same API that non-Pigeon event channel code would use.

The existing static register method is kept to avoid unnecessary breaking changes, but clients no longer need to use it if they want to create the channel directly.

Fixes flutter/flutter#195049

Pre-Review Checklist

Footnotes

  1. Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling. ↩ ↩2

Makes the handler parameter to event channel registration nullable, and
makes passing null/nil unregister the previously registered handler,
allowing clients to unergister handlers to prevent leaks in way that is
consistent with host API unregistration.

Fixes flutter/flutter#195049
@stuartmorgan
stuartmorgan marked this pull request as ready for review October 26, 2026 20:04

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request adds support for removing event channel handler registration in Kotlin and Swift by passing null to the stream handler registration method, updating the generators, generated code, and tests accordingly. The review feedback highlights that using static registeredChannels collections to track active channels in both Kotlin and Swift generators introduces severe memory leaks across FlutterEngine instances. The reviewer suggests directly unregistering the handlers via the binary messenger to avoid static state entirely.

Comment on lines 1476 to 1495
companion object {
fun register(messenger: BinaryMessenger, streamHandler: ${toUpperCamelCase(func.name)}StreamHandler, instanceName: String = "") {
private var registeredChannels = mutableMapOf<String, EventChannel>()

fun register(messenger: BinaryMessenger, streamHandler: ${toUpperCamelCase(func.name)}StreamHandler?, instanceName: String = "") {
var channelName: String = "${makeChannelName(api, func, dartPackageName)}"
if (instanceName.isNotEmpty()) {
channelName += ".\$instanceName"
}
val internalStreamHandler = ${generatorOptions.fileSpecificClassNameComponent}PigeonStreamHandler<${_kotlinTypeForDartType(func.returnType)}>(streamHandler)
EventChannel(messenger, channelName, ${generatorOptions.fileSpecificClassNameComponent}$_pigeonMethodCodec).setStreamHandler(internalStreamHandler)
val previousChannel = registeredChannels.remove(channelName)
if (previousChannel != null) {
previousChannel.setStreamHandler(null)
}
if (streamHandler != null) {
val internalStreamHandler = ${generatorOptions.fileSpecificClassNameComponent}PigeonStreamHandler<${_kotlinTypeForDartType(func.returnType)}>(streamHandler)
val channel = EventChannel(messenger, channelName, ${generatorOptions.fileSpecificClassNameComponent}$_pigeonMethodCodec)
channel.setStreamHandler(internalStreamHandler)
registeredChannels[channelName] = channel
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

Using a static registeredChannels map to keep track of active EventChannels introduces a severe memory leak. In Android, the JVM process (and thus static variables) can persist across multiple FlutterEngine instances (e.g., during activity recreation or in add-to-app scenarios). Because the static map retains references to EventChannel instances from previous engines, those engines and their associated resources will be leaked.

Instead of keeping a static map of channels, we can directly unregister the handler by calling messenger.setMessageHandler(channelName, null). This is because EventChannel.setStreamHandler(null) internally just calls messenger.setMessageHandler(name, null). This completely avoids the need for static state, is thread-safe, and prevents memory leaks.

Suggested change
companion object {
fun register(messenger: BinaryMessenger, streamHandler: ${toUpperCamelCase(func.name)}StreamHandler, instanceName: String = "") {
private var registeredChannels = mutableMapOf<String, EventChannel>()
fun register(messenger: BinaryMessenger, streamHandler: ${toUpperCamelCase(func.name)}StreamHandler?, instanceName: String = "") {
var channelName: String = "${makeChannelName(api, func, dartPackageName)}"
if (instanceName.isNotEmpty()) {
channelName += ".\$instanceName"
}
val internalStreamHandler = ${generatorOptions.fileSpecificClassNameComponent}PigeonStreamHandler<${_kotlinTypeForDartType(func.returnType)}>(streamHandler)
EventChannel(messenger, channelName, ${generatorOptions.fileSpecificClassNameComponent}$_pigeonMethodCodec).setStreamHandler(internalStreamHandler)
val previousChannel = registeredChannels.remove(channelName)
if (previousChannel != null) {
previousChannel.setStreamHandler(null)
}
if (streamHandler != null) {
val internalStreamHandler = ${generatorOptions.fileSpecificClassNameComponent}PigeonStreamHandler<${_kotlinTypeForDartType(func.returnType)}>(streamHandler)
val channel = EventChannel(messenger, channelName, ${generatorOptions.fileSpecificClassNameComponent}$_pigeonMethodCodec)
channel.setStreamHandler(internalStreamHandler)
registeredChannels[channelName] = channel
}
}
}
companion object {
fun register(messenger: BinaryMessenger, streamHandler: ${toUpperCamelCase(func.name)}StreamHandler?, instanceName: String = "") {
var channelName: String = "${makeChannelName(api, func, dartPackageName)}"
if (instanceName.isNotEmpty()) {
channelName += ".\$instanceName"
}
if (streamHandler == null) {
messenger.setMessageHandler(channelName, null)
} else {
val internalStreamHandler = ${generatorOptions.fileSpecificClassNameComponent}PigeonStreamHandler<${_kotlinTypeForDartType(func.returnType)}>(streamHandler)
val channel = EventChannel(messenger, channelName, ${generatorOptions.fileSpecificClassNameComponent}$_pigeonMethodCodec)
channel.setStreamHandler(internalStreamHandler)
}
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

those engines and their associated resources will be leaked.

I'm skeptical that this leaks the entire engine (if it does, the Android engine should get a weak proxy object like iOS got), but will will potentially accumulate channels from plugins that don't do cleanup, which isn't ideal.

More importantly, I forgot to consider multi-engine cases in keying the dictionary, so registering the same plugin on multiple engines will actually stomp the connection.

I can fix the latter by changing the dictionary, but maybe it's better to return an opaque wrapper around the event channel instead, and let that be used for cleanup. It makes the unregistration API different, but it means that nothing can outlive the combination of engine+plugin since there won't be an external reference.

Instead of keeping a static map of channels, we can directly unregister the handler by calling messenger.setMessageHandler(channelName, null). This is because EventChannel.setStreamHandler(null) internally just calls messenger.setMessageHandler(name, null). This completely avoids the need for static state, is thread-safe, and prevents memory leaks.

That is currently how it works, but we shouldn't rely on it. Exhibit A, the iOS behavior, and my comment about that it the issue.

Comment on lines 2674 to 2693
class ${toUpperCamelCase(func.name)}StreamHandler: PigeonEventChannelWrapper<${_swiftTypeForDartType(func.returnType)}> {
private static var registeredChannels: [String: FlutterEventChannel] = [:]
static func register(with messenger: FlutterBinaryMessenger,
instanceName: String = "",
streamHandler: ${toUpperCamelCase(func.name)}StreamHandler) {
streamHandler: ${toUpperCamelCase(func.name)}StreamHandler?) {
var channelName = "${makeChannelName(api, func, dartPackageName)}"
if !instanceName.isEmpty {
channelName += ".\\(instanceName)"
}
let internalStreamHandler = PigeonStreamHandler<${_swiftTypeForDartType(func.returnType)}>(wrapper: streamHandler)
let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: ${_getMethodCodecVarName(generatorOptions)})
channel.setStreamHandler(internalStreamHandler)
if let previousChannel = registeredChannels.removeValue(forKey: channelName) {
previousChannel.setStreamHandler(nil)
}
if let streamHandler = streamHandler {
let internalStreamHandler = PigeonStreamHandler<${_swiftTypeForDartType(func.returnType)}>(wrapper: streamHandler)
let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: ${_getMethodCodecVarName(generatorOptions)})
channel.setStreamHandler(internalStreamHandler)
registeredChannels[channelName] = channel
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

Using a static registeredChannels dictionary to keep track of active FlutterEventChannels introduces a severe memory leak. In iOS, static variables persist as long as the app process is alive. If the FlutterEngine is destroyed and recreated, the static dictionary will retain references to FlutterEventChannel instances from the previous engine, leaking the entire engine and its resources.

Instead of keeping a static dictionary of channels, we can directly unregister the handler by calling messenger.setMessageHandlerOnChannel(channelName, binaryMessageHandler: nil). This is because FlutterEventChannel.setStreamHandler(nil) internally just calls [_messenger setMessageHandlerOnChannel:_name binaryMessageHandler:nil]. This completely avoids the need for static state, is thread-safe, and prevents memory leaks.

Suggested change
class ${toUpperCamelCase(func.name)}StreamHandler: PigeonEventChannelWrapper<${_swiftTypeForDartType(func.returnType)}> {
private static var registeredChannels: [String: FlutterEventChannel] = [:]
static func register(with messenger: FlutterBinaryMessenger,
instanceName: String = "",
streamHandler: ${toUpperCamelCase(func.name)}StreamHandler) {
streamHandler: ${toUpperCamelCase(func.name)}StreamHandler?) {
var channelName = "${makeChannelName(api, func, dartPackageName)}"
if !instanceName.isEmpty {
channelName += ".\\(instanceName)"
}
let internalStreamHandler = PigeonStreamHandler<${_swiftTypeForDartType(func.returnType)}>(wrapper: streamHandler)
let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: ${_getMethodCodecVarName(generatorOptions)})
channel.setStreamHandler(internalStreamHandler)
if let previousChannel = registeredChannels.removeValue(forKey: channelName) {
previousChannel.setStreamHandler(nil)
}
if let streamHandler = streamHandler {
let internalStreamHandler = PigeonStreamHandler<${_swiftTypeForDartType(func.returnType)}>(wrapper: streamHandler)
let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: ${_getMethodCodecVarName(generatorOptions)})
channel.setStreamHandler(internalStreamHandler)
registeredChannels[channelName] = channel
}
}
}
class ${toUpperCamelCase(func.name)}StreamHandler: PigeonEventChannelWrapper<${_swiftTypeForDartType(func.returnType)}> {
static func register(with messenger: FlutterBinaryMessenger,
instanceName: String = "",
streamHandler: ${toUpperCamelCase(func.name)}StreamHandler?) {
var channelName = "${makeChannelName(api, func, dartPackageName)}"
if !instanceName.isEmpty {
channelName += ".\\(instanceName)"
}
if streamHandler == nil {
messenger.setMessageHandlerOnChannel(channelName, binaryMessageHandler: nil)
} else {
let internalStreamHandler = PigeonStreamHandler<${_swiftTypeForDartType(func.returnType)}>(wrapper: streamHandler)
let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: ${_getMethodCodecVarName(generatorOptions)})
channel.setStreamHandler(internalStreamHandler)
}
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is because FlutterEventChannel.setStreamHandler(nil) internally just calls [_messenger setMessageHandlerOnChannel:_name binaryMessageHandler:nil].

This is a lie, which is why the PR didn't do that.

@stuartmorgan
stuartmorgan marked this pull request as draft October 26, 2026 22:50
@stuartmorgan
stuartmorgan marked this pull request as ready for review October 28, 2026 18:46

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request updates Pigeon to support removing event channel handler registrations in Kotlin and Swift by passing null to the stream handler registration method. It introduces new event channel wrapper classes in both Kotlin and Swift generators, and updates the register method to return these channel instances. Redundant return statements and open modifiers are also cleaned up in the Kotlin generator. The review feedback suggests adding the @discardableResult attribute to the generated Swift register method to prevent compiler warnings when callers ignore the returned channel instance.

Comment on lines 2703 to 2711
static func register(
with messenger: FlutterBinaryMessenger,
instanceName: String = "",
streamHandler: $streamHandlerClassName
) -> $channelClassName {
let channel = $channelClassName(messenger: messenger, instanceName: instanceName)
channel.setStreamHandler(streamHandler)
return channel
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

In Swift, if a function returns a value and the caller does not use it, the compiler will emit an unused result warning unless the function is annotated with @discardableResult. Since the register method's return value can be safely ignored (as documented in its doc comment), we should add @discardableResult to prevent compiler warnings in existing codebases that upgrade to this version of Pigeon without capturing the returned channel.

Suggested change
static func register(
with messenger: FlutterBinaryMessenger,
instanceName: String = "",
streamHandler: $streamHandlerClassName
) -> $channelClassName {
let channel = $channelClassName(messenger: messenger, instanceName: instanceName)
channel.setStreamHandler(streamHandler)
return channel
}
@discardableResult
static func register(
with messenger: FlutterBinaryMessenger,
instanceName: String = "",
streamHandler: $streamHandlerClassName
) -> $channelClassName {
let channel = $channelClassName(messenger: messenger, instanceName: instanceName)
channel.setStreamHandler(streamHandler)
return channel
}

@stuartmorgan

Copy link
Copy Markdown
Contributor Author

Design notes:

  • Because the underlying API on iOS requires keeping the original EventChannel around to correctly unregister, I couldn't make this work in a way that wasn't stateful for clients without causing lifetime extensions for multi-engine cases (see comments in the Gemini review above).
  • My next thought was to make a single opaque, generic PigeonEventChannelRegistration object that internally stored the EventChannel and provide an unregister static method that took that registration (similar to some iOS/macOS SDK observer API patterns). However, that ran into the PigeonError problem of collisions if there were multiple files. I could have done the filename-based approach, but that felt clunky for a public opaque object so I decided to just factor our the channel and provide an API that more closely mirrors the non-Pigeon Flutter API. That avoids naming issues, and also makes the pattern familiar to anyone who has used the base event channel APIs.

@stuartmorgan

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces support for removing event channel handler registration by passing null to the stream handler registration method in Pigeon. It updates the Kotlin and Swift generators to create a typed channel class for event streams, allowing for more flexible handler management. Additionally, it includes updates to the test plugins to exercise the new handler removal functionality. I have provided feedback regarding the use of @discardableResult in Swift to maintain backward compatibility and improve code cleanliness in the test suite.

Comment thread packages/pigeon/lib/src/swift/swift_generator.dart
indent.addScoped('{', '}', () {
if (customType.type == CustomTypes.customClass) {
indent.write('return (readValue(buffer) as? List<Any?>)?.let ');
indent.write('(readValue(buffer) as? List<Any?>)?.let ');

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This and the same change below are opportunistic cleanup of warnings I noticed in the IDE while prototyping solutions.

}

interface ${generatorOptions.fileSpecificClassNameComponent}PigeonEventChannelWrapper<T> {
open fun onListen(p0: Any?, sink: PigeonEventSink<T>) {}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same with these (open is the default for interfaces).

@tarrinneal tarrinneal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not sure why I didn't do this when I put this all together.

Comment thread packages/pigeon/CHANGELOG.md Outdated
Comment on lines +3 to +4
* [kotlin,swift] Adds support for removing event channel handler registration
by passing `null` to the stream handler registration method.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: It looks like this wording is from the earlier iteration where register accepted null—consider updating it to mention the new *EventChannel class and setStreamHandler(null).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Oops, I rewrote the doc comments but forgot about the changelog. Fixed.

Comment on lines +64 to 75
// Store one handler to exercise the handler removal codepath, and discard
// the other to test that the return value can be safely ignored.
let streamHandlerOne = SendConsistentNumbers(numberToSend: 1)
let channelOne = StreamConsistentNumbersStreamHandler.register(
with: binaryMessenger,
instanceName: "1",
streamHandler: streamHandlerOne)
streamHandlerOne.onCompleted = { channelOne.setStreamHandler(nil) }
StreamConsistentNumbersStreamHandler.register(
with: binaryMessenger, instanceName: "1",
streamHandler: SendConsistentNumbers(numberToSend: 1))
StreamConsistentNumbersStreamHandler.register(
with: binaryMessenger, instanceName: "2",
with: binaryMessenger,
instanceName: "2",
streamHandler: SendConsistentNumbers(numberToSend: 2))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In TestPlugin.swift, channelOne.setStreamHandler(nil) runs in onCompleted after the 10th event, whereas TestPlugin.kt only calls eventChannelOne?.setStreamHandler(null) in onDetachedFromEngine, and integration_tests.dart finishes after the first stream completes. Would it be worth also unregistering on completion in TestPlugin.kt and asserting in integration_tests.dart that listening to instance "1" a second time fails (while instance "2" still succeeds)?

@stuartmorgan stuartmorgan Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I've moved the unregistration to match Swift. I wrote the Kotlin version first, but there's no corresponding detach call on the iOS side in the plugin currently which is why it was different in the first place. It's easy enough to have Kotlin match Swift so that the tests are easier to understand as a whole.

I don't think re-listening on the Dart side would fail; IIRC listening to a channel without a native handler just gives an empty stream. This is such a trivial passthrough now that I don't think we need to specifically test the behavior, since it would be very hard to regress without breaking compilation.

@stuartmorgan stuartmorgan added the autosubmit Merge PR when tree becomes green via auto submit App label Oct 30, 2026
@auto-submit
auto-submit Bot merged commit 3742a4e into flutter:main Oct 30, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

autosubmit Merge PR when tree becomes green via auto submit App CICD Run CI/CD p: pigeon platform-android platform-ios platform-macos

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[pigeon] Provide a way to remove event channel handlers

2 participants