-
Notifications
You must be signed in to change notification settings - Fork 3.3k
[pigeon]fix "as Any" workaround due to nested optional #3658
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]fix "as Any" workaround due to nested optional #3658
Conversation
I probably also need to fix tests other than the |
Thanks for digging into this!
We just did a bunch of this the last time we found a case where By keeping both flavors of
This seems like it would be straightforward since there's a well-defined boundary. It's there a downside to doing that?
Are we losing semantic meaning though? It's explicitly documented that I'm still struggling to understand what the semantic difference between |
Alternately, maybe our purge of I don't fully understand what exactly the criteria are for implicit creation of nested optionals. |
I would be interested to learn about that case.
I don't think there's any downside, other than the optionality becomes implicit.
The criteria are (1) passing an For example, this short code snippet:
So, solution 1 avoids (1) an |
Here's another example that may help better understand why the wrapping:
Similarly, the The section "How did we end up with Any??" in PR description may also be helpful. |
My initial take away from this note is that Apple discourages using (Though this is just my initial thought. I can do more research on this) |
Thanks, this example makes sense to me.
That's... ugh. I guess I can see how the chain of other decisions leads to this but it seems really problematic, because it's consistent in some ways, but very inconsistent in other ways. The I'd be very curious to talk to Swift developers to understand why they made
Right, I had read that, but without the foundation of the simpler cases above I was not following why that chain caused an |
That seems reasonable. So if we want to go that route, we need to sweep the entire generator and undo a bunch of the changes from #3284 that were made based on my conclusion in #3284 (comment). It seems likely that all of our use of |
My guess is that the optional wrapping is done at compile time. The compiler simply checks the static type of the argument vs the parameter, and insert the wrapping instruction there if needed. So at this moment dynamic type is unknown. They could have also added some extra logic specific about
Yeah I'm also curious if Apple regrets this decision. I was surprised to learn the different behavior between Swift and Kotlin.
True. Yeah wasn't clear there.
Yeah. I think this PR cleans up some of them already. I can also take a look at what else is missing. |
Thinking about it again, likely it all starts from the decision to use I can see why they want to use |
From skimming the generated output here, it looks like collection fields are still wrong, and if Obj-C unknown types are in fact mapped to |
@hellohuanlin Did you still want to move forward with this? |
@stuartmorgan yes. I plan to work on it this week. |
This means that there will unfortunately be a breaking change - a raw dart I think it's fine, because most people will likely explicitly write
To clarify a bit - since objc |
We are generally pretty relaxed about making breaking changes (as long as they affect compilation, not silently change runtime behavior) for Pigeon, since it would only show up for plugin developers when they are actively updating and re-running Pigeon generation. That's why Pigeon's major version is so high already. As long as we're confident that we're changing to a better reflection of the types we want to express, it's fine to do a breaking change for. |
I just found another interesting bug related to implicit optional (which does not cause "nested optional", but will still be fixed by this PR): packages/packages/pigeon/platform_tests/test_plugin/ios/Classes/CoreTests.gen.swift Lines 563 to 564 in c53db71
This is supposed to crash if The correct codegen should instead be:
This also means that the "Solution 2" in my PR description is problematic - it only fixes the nested optional issue, but it won't fix the issue above that silences the type mismatch (updating PR description to reflect that) |
c53db71
to
326d5c1
Compare
@@ -605,8 +605,7 @@ import FlutterMacOS | |||
'nullable enums require special code that this helper does not supply'); | |||
return '${_swiftTypeForDartType(type)}(rawValue: $value as! Int)!'; | |||
} else if (type.baseName == 'Object') { | |||
// Special-cased to avoid warnings about using 'as' with Any. | |||
return value; | |||
return value + (type.isNullable ? '' : '!'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is to fix a separate bug that silences type mismatch. more details: #3658 (comment)
} else if (type.baseName == 'Map') { | ||
return '[AnyHashable: Any]'; | ||
return '[AnyHashable: Any?]'; | ||
} else { | ||
return 'Any'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This else
block (and the corresponding else
in Line 753) is not used, since the caller only call this function with List/Map
types:
if (type.baseName == 'List' || type.baseName == 'Map') {
return _swiftTypeForBuiltinGenericDartType(type);
}
I would either write an assert, or split it into 2 separate helpers (for list and map). But for now I'll leave it here to keep this PR focused (I can update it in separate PR)
@@ -28,13 +28,14 @@ class EchoBinaryMessenger: NSObject, FlutterBinaryMessenger { | |||
|
|||
guard | |||
let args = self.codec.decode(message) as? [Any?], | |||
let firstArg: Any? = nilOrValue(args.first) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
args.first
is another nested optional (Any??
) - outer nil represents "there's no first element, or list is empty", and inner nil represents "there is first element, but it is nil".
When passing it to nilOrValue
, it gets coerced into Any?
, hence the compiler warning in Xcode (Bonus: we should make this an error instead of warning, will do in separate PR).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is very strange, I would expect the old code and the new code to behave in the same way.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The old code worked because it has "as Any" intermediate cast inside nilOrValue function. The new code here avoids implicitly optional completely, so no need the hack anymore.
auto label is removed for flutter/packages, pr: 3658, due to - This commit is not mergeable and has conflicts. Please rebase your PR and fix all the conflicts. |
fix tests fix another nested optional
b87d798
to
89dca42
Compare
## The problem This PR fixes a weird casting behavior discussed [here](flutter#3545 (comment)): ``` private func nilOrValue<T>(_ value: Any?) -> T? { if value is NSNull { return nil } return (value as Any) as! T? // <- HERE } ``` Without this intermediate `as Any` cast, [these 3 tests](https://github.com/flutter/packages/blob/5662a7e5799c723f76e9589a75c9d9310e2ba8c1/packages/pigeon/platform_tests/test_plugin/example/ios/RunnerTests/RunnerTests.swift#L10-L29) would crash with `SIGABRT: Could not cast value of type 'Swift.Optional<Any>' (0x7ff865e84b38) to 'Swift.String' (0x7ff865e7de08).` ## Investigation The crash happens because `value` here is actually of type `Any??` (nested optional!). When it crashes, the debugger simply shows `value` is `nil`. But if we print in `lldb`, the `value` here is actually an inner `Optional.none` case nested by an outer `Optional.some` case. ### Why does `Any??` crash Since outer case is `some`, it fails to force cast to `T?` (e.g. `String?`) due to type mismatch. ### How did we end up with `Any??` It's related to the signature of these 3 functions: - `func toList() -> [Any?]` - `func fromList(args: [Any])` - `func nilOrValue<T>(_ value: Any?) -> T?` Firstly `toList` returns `nil` (of type `Any?`) as the first element of array. Then the type gets coerced as an `Any` type in `fromList`. Then because `nilOrValue` takes `Any?`, this `nil` value gets wrapped by an `Optional.some`. Hence the nested optional. ## Workarounds ### Workaround 1: `as Any` This is the current code [in this PR](https://github.com/flutter/packages/pull/3545/files#r1155061282). When casting `Optional.some(nil) as Any`, it erases the outer Optional, so no problem casting to `T?`. ### Workaround 2: Handle with nested optional directly: ``` private func nilOrValue<T>(_ value: Any?) -> T? { if value is NSNull { return nil } // `if let` deals with "outer some" case and then erase the outer Optional if let val = value { // here returns "outer some + inner some" or "outer some + inner none" return val as! T? } // here returns "outer none" return nil } ``` A similar version of this was also [attempted in this PR](https://github.com/flutter/packages/pull/3545/files/241f0e31e32917f5501dab11f81ab0fbf064687f#diff-bfdb6a91beb03a906435e77e0168117f3f3977ee4d6f8bcaa1724156ae4dc27cR647-R650). It just that we did not know why that worked previously, and now we know! ### Workaround 3 Casting value to nested optional (`T??`), then stripe the outer optional ``` private func nilOrValue<T>(_ value: Any?) -> T? { if value is NSNull { return nil } return (value as! T??) ?? nil } ``` ## Solutions These above workarounds handle nested optionals. However, **a real solution should prevent nested optionals from happening in the first place**, since they are so tricky. ### Solution 1 (This PR) The nested optional happens when we do cast from `Any?` to `Any` and then wrapped into `Any?`. (Refer to "How did we end up with Any??" section). So the easiest way is just to use `func fromList(args: [Any?])` to match the types of `func toList` and `func nilOrValue`. ### Solution 2 Solution 2 is the opposite - avoid using `Any?` as much as possible. Drawbacks compare to Solution 1: a. When inter-op with ObjC, `nullable id` is exported as `Any?`. So we can't 100% prevent `Any?` usage. Though this can be addressed by immediately cast it to `Any`. b. Losing of semantic meaning of `Any?` that it <s>can</s> must be optional. The hidden/implicit optional **is** the culprit here in the first place. c. While this solution fixes the nested optional issue, it does not address all issues related to implicit optional. For example: https://github.com/flutter/packages/blob/c53db71f496b436e48629a8f3e4152c48e63cd66/packages/pigeon/platform_tests/test_plugin/ios/Classes/CoreTests.gen.swift#L563-L564 This is supposed to crash if `args[0]` is `nil`. However, the crash is silenced because `as! [Any]` will make `args[0]` an implicit optional! The correct codegen should instead be: ``` let args = message as! [Any?] let anObjectArg = args[0]! ``` ### Solution 3 Just remove `as Any` and update the test. The nested optional won't happen in production code, because ObjC `NSArray` contains `NSNull` rather than `nil` when exporting to Swift. We can simply fix [the tests](https://github.com/flutter/packages/blob/5662a7e5799c723f76e9589a75c9d9310e2ba8c1/packages/pigeon/platform_tests/test_plugin/example/ios/RunnerTests/RunnerTests.swift#L10-L29) by replacing `nil`s with `NSNull`s. However, if we were to re-write engine's codec to Swift, it's actually better practice to use `nil` and not `NSNull` in the array. ## Additional TODO We would've caught this earlier if this were an error rather than warning in our unit test.  *List which issues are fixed by this PR. You must list at least one issue.* flutter#3545 (comment) *If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].*
…3889) This will make it easier to spot issues where we accidentally create an "implicit optional". For more details about why implicit optional is bad, see #3658 For example, the following warning was ignored previously:  *List which issues are fixed by this PR. You must list at least one issue.* Mentioned in PR: #3658 (comment) *If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].*
flutter/packages@f163786...407b7da 2023-05-03 [email protected] Update Cirrus to Xcode 14.3 (flutter/packages#3890) 2023-05-03 [email protected] [file_selector] Deprecates `macUTIs` (flutter/packages#3888) 2023-05-03 [email protected] [pigeon]enable treat warning as errors for swift code in unit test (flutter/packages#3889) 2023-05-02 [email protected] Update xcode to 14e222b (flutter/packages#3868) 2023-05-02 [email protected] [pigeon]fix "as Any" workaround due to nested optional (flutter/packages#3658) 2023-05-02 [email protected] [webview_flutter_android] Adds support to accept third party cookies (flutter/packages#3834) 2023-05-02 [email protected] [webview_flutter_wkwebview] Fixes an exception caused by the `onUrlChange` callback returning a null url (flutter/packages#3848) 2023-05-02 [email protected] [google_maps_flutter] [Docs] Note regarding usage within a bounded & an unbound widget (flutter/packages#3691) 2023-05-02 [email protected] [local_auth_android] Fix Android lint warnings (flutter/packages#3764) 2023-05-02 [email protected] [go_router_builder] Support go_router v7 (flutter/packages#3858) 2023-05-02 [email protected] [webview_flutter_wkwebview] Fixes internal enum type and adds unknown enum values (flutter/packages#3812) 2023-05-02 [email protected] [file_selector] Add MIME type support on macOS (flutter/packages#3862) 2023-05-02 [email protected] Roll Flutter from 828a040 to db6074a (12 revisions) (flutter/packages#3881) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/flutter-packages-flutter-autoroll Please CC [email protected],[email protected] on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://bugs.chromium.org/p/skia/issues/entry?template=Autoroller+Bug Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md
Fixes a crash introduced in #3658: ``` static func fromList(_ list: [Any?]) -> NativeAuthSession? { if let userPoolTokensList = list[2] as! [Any?]? {} } ``` where `list[2]` is `NSNull`, which is not an Optional, hence can't be casted to `[Any?]?`. Recall that `nilOrValue` helper is created for this purpose. So the fix is simply: ``` static func fromList(_ list: [Any?]) -> NativeAuthSession? { if let userPoolTokensList: [Any?] = nilOrValue(list[2]) {} // <- HERE } ``` ## Why didn't we catch this regression Missing unit tests - we did not test `NSNull` fields to ensure `nilOrValue` works for them. ## Why did it work in previous version? It's surprising that this worked fine before! The original code is: ``` static func fromList(_ list: [Any]) -> NativeAuthSession? { if let userPoolTokensList = list[2] as! [Any]? {} } ``` From [my previous PR](flutter/flutter#129283), we know that list[2] is an implicit optional (that contains a NSNull value). I think Swift made an exception here when casting implicit optional NSNull (either intentionally or unintentionally). *List which issues are fixed by this PR. You must list at least one issue.* Fixes flutter/flutter#129283 *If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].*
## The problem This PR fixes a weird casting behavior discussed [here](flutter#3545 (comment)): ``` private func nilOrValue<T>(_ value: Any?) -> T? { if value is NSNull { return nil } return (value as Any) as! T? // <- HERE } ``` Without this intermediate `as Any` cast, [these 3 tests](https://github.com/flutter/packages/blob/5662a7e5799c723f76e9589a75c9d9310e2ba8c1/packages/pigeon/platform_tests/test_plugin/example/ios/RunnerTests/RunnerTests.swift#L10-L29) would crash with `SIGABRT: Could not cast value of type 'Swift.Optional<Any>' (0x7ff865e84b38) to 'Swift.String' (0x7ff865e7de08).` ## Investigation The crash happens because `value` here is actually of type `Any??` (nested optional!). When it crashes, the debugger simply shows `value` is `nil`. But if we print in `lldb`, the `value` here is actually an inner `Optional.none` case nested by an outer `Optional.some` case. ### Why does `Any??` crash Since outer case is `some`, it fails to force cast to `T?` (e.g. `String?`) due to type mismatch. ### How did we end up with `Any??` It's related to the signature of these 3 functions: - `func toList() -> [Any?]` - `func fromList(args: [Any])` - `func nilOrValue<T>(_ value: Any?) -> T?` Firstly `toList` returns `nil` (of type `Any?`) as the first element of array. Then the type gets coerced as an `Any` type in `fromList`. Then because `nilOrValue` takes `Any?`, this `nil` value gets wrapped by an `Optional.some`. Hence the nested optional. ## Workarounds ### Workaround 1: `as Any` This is the current code [in this PR](https://github.com/flutter/packages/pull/3545/files#r1155061282). When casting `Optional.some(nil) as Any`, it erases the outer Optional, so no problem casting to `T?`. ### Workaround 2: Handle with nested optional directly: ``` private func nilOrValue<T>(_ value: Any?) -> T? { if value is NSNull { return nil } // `if let` deals with "outer some" case and then erase the outer Optional if let val = value { // here returns "outer some + inner some" or "outer some + inner none" return val as! T? } // here returns "outer none" return nil } ``` A similar version of this was also [attempted in this PR](https://github.com/flutter/packages/pull/3545/files/241f0e31e32917f5501dab11f81ab0fbf064687f#diff-bfdb6a91beb03a906435e77e0168117f3f3977ee4d6f8bcaa1724156ae4dc27cR647-R650). It just that we did not know why that worked previously, and now we know! ### Workaround 3 Casting value to nested optional (`T??`), then stripe the outer optional ``` private func nilOrValue<T>(_ value: Any?) -> T? { if value is NSNull { return nil } return (value as! T??) ?? nil } ``` ## Solutions These above workarounds handle nested optionals. However, **a real solution should prevent nested optionals from happening in the first place**, since they are so tricky. ### Solution 1 (This PR) The nested optional happens when we do cast from `Any?` to `Any` and then wrapped into `Any?`. (Refer to "How did we end up with Any??" section). So the easiest way is just to use `func fromList(args: [Any?])` to match the types of `func toList` and `func nilOrValue`. ### Solution 2 Solution 2 is the opposite - avoid using `Any?` as much as possible. Drawbacks compare to Solution 1: a. When inter-op with ObjC, `nullable id` is exported as `Any?`. So we can't 100% prevent `Any?` usage. Though this can be addressed by immediately cast it to `Any`. b. Losing of semantic meaning of `Any?` that it <s>can</s> must be optional. The hidden/implicit optional **is** the culprit here in the first place. c. While this solution fixes the nested optional issue, it does not address all issues related to implicit optional. For example: https://github.com/flutter/packages/blob/c53db71f496b436e48629a8f3e4152c48e63cd66/packages/pigeon/platform_tests/test_plugin/ios/Classes/CoreTests.gen.swift#L563-L564 This is supposed to crash if `args[0]` is `nil`. However, the crash is silenced because `as! [Any]` will make `args[0]` an implicit optional! The correct codegen should instead be: ``` let args = message as! [Any?] let anObjectArg = args[0]! ``` ### Solution 3 Just remove `as Any` and update the test. The nested optional won't happen in production code, because ObjC `NSArray` contains `NSNull` rather than `nil` when exporting to Swift. We can simply fix [the tests](https://github.com/flutter/packages/blob/5662a7e5799c723f76e9589a75c9d9310e2ba8c1/packages/pigeon/platform_tests/test_plugin/example/ios/RunnerTests/RunnerTests.swift#L10-L29) by replacing `nil`s with `NSNull`s. However, if we were to re-write engine's codec to Swift, it's actually better practice to use `nil` and not `NSNull` in the array. ## Additional TODO We would've caught this earlier if this were an error rather than warning in our unit test.  *List which issues are fixed by this PR. You must list at least one issue.* flutter#3545 (comment) *If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].*
…lutter#3889) This will make it easier to spot issues where we accidentally create an "implicit optional". For more details about why implicit optional is bad, see flutter#3658 For example, the following warning was ignored previously:  *List which issues are fixed by this PR. You must list at least one issue.* Mentioned in PR: flutter#3658 (comment) *If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].*
The problem
This PR fixes a weird casting behavior discussed here:
Without this intermediate
as Any
cast, these 3 tests would crash withSIGABRT: Could not cast value of type 'Swift.Optional<Any>' (0x7ff865e84b38) to 'Swift.String' (0x7ff865e7de08).
Investigation
The crash happens because
value
here is actually of typeAny??
(nested optional!). When it crashes, the debugger simply showsvalue
isnil
. But if we print inlldb
, thevalue
here is actually an innerOptional.none
case nested by an outerOptional.some
case.Why does
Any??
crashSince outer case is
some
, it fails to force cast toT?
(e.g.String?
) due to type mismatch.How did we end up with
Any??
It's related to the signature of these 3 functions:
func toList() -> [Any?]
func fromList(args: [Any])
func nilOrValue<T>(_ value: Any?) -> T?
Firstly
toList
returnsnil
(of typeAny?
) as the first element of array. Then the type gets coerced as anAny
type infromList
. Then becausenilOrValue
takesAny?
, thisnil
value gets wrapped by anOptional.some
. Hence the nested optional.Workarounds
Workaround 1:
as Any
This is the current code in this PR. When casting
Optional.some(nil) as Any
, it erases the outer Optional, so no problem casting toT?
.Workaround 2:
Handle with nested optional directly:
A similar version of this was also attempted in this PR. It just that we did not know why that worked previously, and now we know!
Workaround 3
Casting value to nested optional (
T??
), then stripe the outer optionalSolutions
These above workarounds handle nested optionals. However, a real solution should prevent nested optionals from happening in the first place, since they are so tricky.
Solution 1 (This PR)
The nested optional happens when we do cast from
Any?
toAny
and then wrapped intoAny?
. (Refer to "How did we end up with Any??" section).So the easiest way is just to use
func fromList(args: [Any?])
to match the types offunc toList
andfunc nilOrValue
.Solution 2
Solution 2 is the opposite - avoid using
Any?
as much as possible.Drawbacks compare to Solution 1:
a. When inter-op with ObjC,
nullable id
is exported asAny?
. So we can't 100% preventAny?
usage. Though this can be addressed by immediately cast it toAny
.b. Losing of semantic meaning of
Any?
that itcanmust be optional. The hidden/implicit optional is the culprit here in the first place.c. While this solution fixes the nested optional issue, it does not address all issues related to implicit optional. For example:
packages/packages/pigeon/platform_tests/test_plugin/ios/Classes/CoreTests.gen.swift
Lines 563 to 564 in c53db71
This is supposed to crash if
args[0]
isnil
. However, the crash is silenced becauseas! [Any]
will makeargs[0]
an implicit optional!The correct codegen should instead be:
Solution 3
Just remove
as Any
and update the test.The nested optional won't happen in production code, because ObjC
NSArray
containsNSNull
rather thannil
when exporting to Swift.We can simply fix the tests by replacing
nil
s withNSNull
s.However, if we were to re-write engine's codec to Swift, it's actually better practice to use
nil
and notNSNull
in the array.Additional TODO
We would've caught this earlier if this were an error rather than warning in our unit test.
List which issues are fixed by this PR. You must list at least one issue.
#3545 (comment)
If you had to change anything in the flutter/tests repo, include a link to the migration guide as per the breaking change policy.
Pre-launch Checklist
dart format
.)[shared_preferences]
pubspec.yaml
with an appropriate new version according to the pub versioning philosophy, or this PR is exempt from version changes.CHANGELOG.md
to add a description of the change, following repository CHANGELOG style.///
).If you need help, consider asking for advice on the #hackers-new channel on Discord.