Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[pigeon]: Support Swift async/await and Kotlin suspend methods #8341

Open
wants to merge 47 commits into
base: main
Choose a base branch
from

Conversation

feduke-nukem
Copy link

@feduke-nukem feduke-nukem commented Dec 23, 2024

  • adds support of generating Swift async/await and Kotlin suspend methods in HostApi.
  • adds Async annotation and AsyncType.callback/AsyncType.await to specify the style of generating async methods for Swift and Kotlin

Resolves #123867, resolves #147283

Pre-launch Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

@feduke-nukem feduke-nukem changed the title [pigeon]: added support of modern asynchronous api for Swift and Kotlin [pigeon]: Support of modern asynchronous api for Swift and Kotlin Dec 23, 2024
@@ -25,6 +25,16 @@ private class PigeonApiImplementation: ExampleHostApi {
}
completion(.success(true))
}

func sendMessageModernAsync(message: MessageData) async throws -> Bool {
Copy link
Contributor

Choose a reason for hiding this comment

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

you can just overload the function name func sendMessage(...)

Copy link
Author

Choose a reason for hiding this comment

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

I don't think that I understood.

It's generated Host API code. We could not overload functions in Dart even if we do in Swift

@@ -25,6 +25,16 @@ private class PigeonApiImplementation: ExampleHostApi {
}
completion(.success(true))
}

func sendMessageModernAsync(message: MessageData) async throws -> Bool {
try? await Task.sleep(nanoseconds: 2_000_000_000)
Copy link
Contributor

Choose a reason for hiding this comment

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

is this used for unit test? we shouldn't wait for so long as it slows down the tests

Copy link
Author

@feduke-nukem feduke-nukem Dec 23, 2024

Choose a reason for hiding this comment

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

This is used in example app for demonstration purposes

I am not sure if it is used in any tests. I could remove this code if it is necessary

Copy link
Author

Choose a reason for hiding this comment

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

Removed Task.sleep

Task {
do {
let result = try await api.sendMessageModernAsync(message: messageArg)
DispatchQueue.main.async {
Copy link
Contributor

Choose a reason for hiding this comment

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

you can do Task { @MainActor } here.

Copy link
Author

Choose a reason for hiding this comment

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

you can do Task { @MainActor } here.

Do you mean:

            sendMessageModernAsyncChannel.setMessageHandler { message, reply in
                let args = message as! [Any?]
                let messageArg = args[0] as! MessageData
                Task {
                    do { @MainActor
                        let result = try await api.sendMessageModernAsync(message: messageArg)
                        reply(wrapResult(result))

                    } catch {
                        reply(wrapError(error))
                    }
                }
            }

Will it lead to executing api.sendMessageModernAsync on main thread that could block it?

Copy link
Contributor

Choose a reason for hiding this comment

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

No, i meant you can replace DispatchQueue.main.async {} with Task { @MainActor}

Copy link
Author

Choose a reason for hiding this comment

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

No, i meant you can replace DispatchQueue.main.async {} with Task { @MainActor}

What's the point/profit?

Copy link
Contributor

Choose a reason for hiding this comment

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

The purpose of swift concurrency is to replace GCD. So since you are using swift concurrency here, there's no reason to use GCD at all.

Copy link
Author

@feduke-nukem feduke-nukem Dec 23, 2024

Choose a reason for hiding this comment

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

GCD

Okay I get it.

I am not very familiar with swift concurrency actually. Is that what you meant?

sendMessageModernAsyncChannel.setMessageHandler { message, reply in
    let args = message as! [Any?]
    let messageArg = args[0] as! MessageData
    Task {
        do {
            let result = try await api.sendMessageModernAsync(message: messageArg)

            await Task { @MainActor in
                reply(wrapResult(result))
            }

        } catch {
            await Task { @MainActor in
                reply(wrapError(error))
            }
        }
    }
}

Maybe we could use MainActor.run instead? I think it is more readable.

sendMessageModernAsyncChannel.setMessageHandler { message, reply in
    let args = message as! [Any?]
    let messageArg = args[0] as! MessageData
    Task {
        do {
            let result = try await api.sendMessageModernAsync(message: messageArg)

            await MainActor.run(){
                reply(wrapResult(result))
            }

        } catch {
            await MainActor.run(){
                reply(wrapError(error))
            }
        }
    }
}

Copy link
Contributor

Choose a reason for hiding this comment

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

In your first chunk of code, don't put await in front of Task.

For this particular case, you can use MainActor.run { ... }, since reply doesn't require an async context.

Copy link
Author

Choose a reason for hiding this comment

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

@feduke-nukem feduke-nukem force-pushed the issue-123867-async-await branch from a8df692 to 583d6d7 Compare December 23, 2024 22:00
@feduke-nukem
Copy link
Author

@hellohuanlin @LouiseHsu @tarrinneal CI check fails:

Changed packages: pigeon
[0:00] Running for packages/pigeon...
  No version change.
  Found NEXT; validating next version in the CHANGELOG.
No version change found, but the change to this package could not be verified to be exempt
from version changes according to repository policy.
If this is a false positive, please comment in the PR to explain why the PR
is exempt, and add (or ask your reviewer to add) the "override: no versioning needed" label.

Do I need to bump version in CHANGELOG.md? Currently I append related to pr changes below NEXT label.

let messageArg = args[0] as! MessageData
Task {
let result = await api.sendMessageModernAsync(message: messageArg)
await MainActor.run {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why doesn't the old version switch thread? Is setMessageHandler's callback guaranteed to be called on main thread?

Copy link
Author

Choose a reason for hiding this comment

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

The old version uses the callback approach that doesn't introduce asynchronous things like task so there was no need to switch to main to reply via channel

Copy link
Author

@feduke-nukem feduke-nukem Dec 26, 2024

Choose a reason for hiding this comment

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

Is setMessageHandler's callback guaranteed to be called on main thread?

I guess so as it is platform channel way of communication

Have found implementation where I see dispatch_async(dispatch_get_main_queue()

Copy link
Contributor

Choose a reason for hiding this comment

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

After marking the func definition as @MainActor discussed here, here we can just do:

Task { @MainActor in
  let result = await api.send...
  reply(wrapResult(result))
}

Copy link
Author

Choose a reason for hiding this comment

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

After marking the func definition as @MainActor discussed here, here we can just do:

Task { @MainActor in
  let result = await api.send...
  reply(wrapResult(result))
}

No problem it could be easily done.

But could you explain to me how it would work?

If we mark the whole function with @mainactor doesn't that mean that some expensive async/await function call could potentially be called at main?

Copy link
Contributor

@hellohuanlin hellohuanlin Dec 26, 2024

Choose a reason for hiding this comment

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

If we mark the whole function with @mainactor doesn't that mean that some expensive async/await function call could potentially be called at main?

The API contract is that this function will be called on main. The old API is also called on main and this does not change that. Marking it as @MainActor will allow compiler to enforce the implementor of this API to only use API that's safe to be used on main thread. That's one of the benefit of swift concurrency.

An expensive aync/await call inside this function will be called on the thread specified by that call. For example,

@MainActor
func sendMessageModernAsync() {
  let foo = await myActor.getExpensiveFoo()
}

Here getExpensiveFoo will be called under the async context of myActor, rather than that of the main actor.

Copy link
Author

Choose a reason for hiding this comment

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

Done

@@ -25,6 +25,10 @@ private class PigeonApiImplementation: ExampleHostApi {
}
completion(.success(true))
}

func sendMessageModernAsync(message: MessageData) async -> Bool {
Copy link
Contributor

Choose a reason for hiding this comment

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

I just looked at the code again - it looks like the contract of pigeon is that the API (e.g. sendMessage) should be called on main thread. If that's the contract we want to preserve, we should mark the async API as main actor.

@MainActor
func sendMessageModernAsync(message: MessageData) async -> Bool {}

Copy link
Author

@feduke-nukem feduke-nukem Dec 26, 2024

Choose a reason for hiding this comment

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

The only thing that should be called on the main thread is dispatching messages via channel

https://docs.flutter.dev/platform-integration/platform-channels#jumping-to-the-main-thread-in-ios

If we mark the whole function with @MainActor doesn't that mean that some expensive async/await function call could potentially be called at main?

Copy link
Contributor

Choose a reason for hiding this comment

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

iiuc both way of communication needs to be on main thread.

The link you add here is calling from platform to dart side. This is the opposite side communication, which needs to be on main as well.

Copy link
Author

Choose a reason for hiding this comment

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

Actually it is the same from the point of dispatching reply via channel, be it call from platform to dart or replying to call from dart

Copy link
Contributor

Choose a reason for hiding this comment

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

it looks like this isn't addressed yet?

Copy link
Author

Choose a reason for hiding this comment

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

I would expect that would be an easy fix.

Is this meant to be a separate pull request?

Copy link
Contributor

Choose a reason for hiding this comment

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

A prequel PR might be cleaner, but it's since it's closely related to this PR and should be small, including it in this PR would probably also be reasonable unless @tarrinneal would prefer it separate.

Copy link
Contributor

Choose a reason for hiding this comment

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

wow, I don't know how that one has been sitting for so long. I'm fine with whatever is easier for @feduke-nukem. I think having a second pr would be cleaner, easier to review. I am fine with either way though.

Copy link
Author

Choose a reason for hiding this comment

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

I prefer separate PRs.

But what would the scenario be in such a case?

  1. I create a new PR.
  2. This PR waits for the new one.
  3. As soon as the new PR is approved or merged, I merge the changes into this PR.

Or are there any other steps?

Copy link
Contributor

Choose a reason for hiding this comment

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

That's it.

Copy link
Contributor

@tarrinneal tarrinneal left a comment

Choose a reason for hiding this comment

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

I'll get to an in depth review as soon as I can. Just wanted to answer your question about failing checks.

Thank you for putting the effort into this pr.

packages/pigeon/CHANGELOG.md Outdated Show resolved Hide resolved
Copy link
Contributor

@tarrinneal tarrinneal left a comment

Choose a reason for hiding this comment

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

Just realized I had unpublished review comments. Still not a complete review though.

packages/pigeon/CHANGELOG.md Outdated Show resolved Hide resolved
packages/pigeon/README.md Outdated Show resolved Hide resolved
packages/pigeon/README.md Outdated Show resolved Hide resolved
Comment on lines 180 to 182
func sendMessageModernAsyncThrows(message: MessageData) async throws -> Bool {
throw PigeonError(code: "code", message: "message", details: "details")
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe throw a (if message.whatever == whatever) return whatever else throw just so people can see some use here.

Copy link
Author

Choose a reason for hiding this comment

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

That's a great idea!

What do you think about also simplifying the core_tests methods by reducing them to one?

  /// Returns the passed object, to test async serialization and deserialization using `await`-style
  /// and Swift can throw an exception.
  @Async(type: AsyncType.await(isSwiftThrows: true))
  @ObjCSelector('echoModernAsyncAllTypesAndNotThrow:')
  @SwiftFunction('echoModernAsyncAllTypesAndNotThrow(_:)')
  AllTypes echoModernAsyncAllTypesAndNotThrow(AllTypes everything);

  /// Returns the passed object, to test async serialization and deserialization using `await`-style
  /// and throws an exception.
  @Async(type: AsyncType.await(isSwiftThrows: true))
  @ObjCSelector('echoModernAsyncAllTypesAndThrow:')
  @SwiftFunction('echoModernAsyncAllTypesAndThrow(_:)')
  AllTypes echoModernAsyncAllTypesAndThrow(AllTypes everything);

to a single AllTypes echoModernAsyncAllTypesAndThrow(AllTypes everything);

This way, we could handle both cases in the tests with the condition you mentioned earlier. Would that work for you?

Copy link
Contributor

Choose a reason for hiding this comment

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

no :( We need to handle both generated code paths, even though they are mostly redundant.

Copy link
Author

Choose a reason for hiding this comment

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

Done

Comment on lines +137 to +145
Future<bool> sendMessageModernAsyncAndThrow(String messageText) {
final MessageData message = MessageData(
code: Code.two,
data: <String, String>{'header': 'this is a header'},
description: 'uri text',
);

return _api.sendMessageModernAsyncThrows(message);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's remove the redundant methods from languages that don't support the exampled feature.

Copy link
Author

Choose a reason for hiding this comment

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

Could you clarify how that should be done? The tool/generate.dart generates code for all supported languages and platforms.

Copy link
Contributor

Choose a reason for hiding this comment

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

I just mean they don't need to be included in the README

Copy link
Author

Choose a reason for hiding this comment

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

I just mean they don't need to be included in the README

This README is generated with script based on the code from example app.

I am not sure what should be done then.

Copy link
Author

Choose a reason for hiding this comment

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

Looks like that it should be isolated HostApi and excluded from being generated for unsupported platforms like EventChannelTests and EventChannelApi was done.

It will significantly reduce the amount of useless code (only Kotlin and Swift is supported)

Copy link
Contributor

Choose a reason for hiding this comment

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

I left another comment with instructions, I don't think it's worth adding a new pigeon file for this. We can leave the code in the other language example files, it just needs to be removed from the README

Copy link
Author

Choose a reason for hiding this comment

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

Have removed for Linux and Windows

Comment on lines 318 to 325
static void handle_send_message_modern_async_throws(
PigeonExamplePackageMessageData* message,
PigeonExamplePackageExampleHostApiResponseHandle* response_handle,
gpointer user_data) {
g_autoptr(FlValue) details = fl_value_new_string("details");
pigeon_example_package_example_host_api_respond_error_send_message_modern_async_throws(
response_handle, "code", "message", details);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Just noting them all to help with tracking (it's useful for me at least)

Copy link
Author

Choose a reason for hiding this comment

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

done

Comment on lines 332 to 333
.send_message_modern_async_throws =
handle_send_message_modern_async_throws};
Copy link
Contributor

Choose a reason for hiding this comment

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

and here

Copy link
Author

Choose a reason for hiding this comment

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

done

@@ -25,6 +25,10 @@ private class PigeonApiImplementation: ExampleHostApi {
}
completion(.success(true))
}

func sendMessageModernAsync(message: MessageData) async -> Bool {
Copy link
Contributor

Choose a reason for hiding this comment

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

wow, I don't know how that one has been sitting for so long. I'm fine with whatever is easier for @feduke-nukem. I think having a second pr would be cleaner, easier to review. I am fine with either way though.

packages/pigeon/lib/ast.dart Outdated Show resolved Hide resolved
Comment on lines +1319 to +1333
if (asynchronousType.isAwait) {
indent.writeln('withContext(Dispatchers.Main) {');
indent.inc();
}
indent.writeln('reply.reply(wrapped)');
if (asynchronousType.isAwait) {
indent.dec();
indent.writeln('}');
}
}
});
if (asynchronousType.isAwait) {
indent.dec();
indent.writeln('}');
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I need to add a method for handling optional indents, and maybe open/close scope across [pigeon] scopes.

Copy link
Contributor

@tarrinneal tarrinneal Jan 22, 2025

Choose a reason for hiding this comment

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

Just a note for myself, you don't need to change anything.

const MessageData& message,
std::function<void(ErrorOr<bool> reply)> result) {
result(FlutterError("code", "message", "details"));
}
};
// #enddocregion cpp-class
Copy link
Contributor

Choose a reason for hiding this comment

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

This comment tells the script where to end the snippet. You can start and stop a snippet multiple times to add a ... and skip lines you don't want included as well. So here I would end the snippet after SendMessageModernAsync then start it again after SendMessageModernAsyncThrows and leave the end comment where it is.

Let me know if that's too confusing of an explanation.

Copy link
Author

Choose a reason for hiding this comment

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

This comment tells the script where to end the snippet. You can start and stop a snippet multiple times to add a ... and skip lines you don't want included as well. So here I would end the snippet after SendMessageModernAsync then start it again after SendMessageModernAsyncThrows and leave the end comment where it is.

Did I get it correct?

// #docregion host-definitions
@HostApi()
abstract class ExampleHostApi {
  String getHostLanguage();

  // These annotations create more idiomatic naming of methods in Objc and Swift.
  @ObjCSelector('addNumber:toNumber:')
  @SwiftFunction('add(_:to:)')
  int add(int a, int b);

  @async
  bool sendMessage(MessageData message);

  // #enddocregion host-definitions
  bool unwantedMethodInReadme();

  bool anotherUnwantedMethodInReadme();
  // #docregion host-definitions
}
// #enddocregion host-definitions

Will produce README:

@HostApi()
abstract class ExampleHostApi {
  String getHostLanguage();

  // These annotations create more idiomatic naming of methods in Objc and Swift.
  @ObjCSelector('addNumber:toNumber:')
  @SwiftFunction('add(_:to:)')
  int add(int a, int b);

  @async
  bool sendMessage(MessageData message);

  // ···
}

Copy link
Contributor

Choose a reason for hiding this comment

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

yup

Copy link
Author

Choose a reason for hiding this comment

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

done

@tarrinneal
Copy link
Contributor

You don't need to merge main unless there is a conflict that needs resolving. It actually makes it harder to review (and sends people an email every time).

@feduke-nukem
Copy link
Author

You don't need to merge main unless there is a conflict that needs resolving. It actually makes it harder to review (and sends people an email every time).

Whoops, my bad.

Didn't mean to do it like that. It won't happen again.

@tarrinneal
Copy link
Contributor

It's not a big deal, just thought I'd let you know

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[pigeon] Support Swift Concurrency style api [pigeon] Implement Swift methods using async & await
4 participants