You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Assume that nullable postcondition attributes such as MemberNotNull only apply when Task-returning calls are directly awaited. Adjust analysis of implementations to permit postconditions to be fulfilled after an await boundary.
Users are requesting the ability to specify postconditions which are only fulfilled after a task-returning method is awaited. We're seeing a relatively high amount of engagement (upvotes and thumbs up) on this compared to many other issues in the nullable space.
A community member observed that there are very few usages of [MemberNotNull] on async methods in open source code, which bodes well for the possibility of breaking the existing behavior if we think it's the right thing for our users. We also think that the existing behavior on async methods is just broken. Currently, when an async method throws an exception, we treat it as fulfilling the postcondition, much like for a non-async method. This is because we think the caller won't actually be able to use whatever variable was expected to be not-null after the call--thus, nothing to warn about.
However, in reality, when an async method throws an exception, it is packaged up into a Task and returned to the caller. It is not thrown until the task is awaited. Because pretty much any code can throw an exception, no async method is able to reliably fulfill nullability postconditions as currently designed. This is similar to an issue we recently fixed in definite assignment.
publicclassC{string?item;[MemberNotNull(nameof(item))]publicasyncTaskMAsync(){Helper();item="a";awaitTask.Yield();// no warnings}publicstaticvoidHelper()=>thrownewException();publicstaticvoidMain(){Cc=newC();vart=c.MAsync();// exception is thrown indirectly through Helper and included in the returned Taskc.item.ToString();// no warning, NRE at runtime}}
Detailed design
We think the most viable option to fix the issue here is to change the existing meaning of [MemberNotNull], as well as other postconditions like [NotNull], on Task-returning methods (methods which return await-able things), such that the postconditions are only applied if the call is immediately awaited. The only safe alternative would be to warn if a nullable postcondition attribute is used at all on an async method, which would be unfortunate.
The requirement to change behavior at call sites for any "awaitable" method call is because async is treated as an implementation detail, and it's not possible to determine whether a method from metadata is async or not. We'd prefer to have uniformity in usage across both source and metadata.
The requirement to immediately await rather than being able to store the task in a variable and await it later, for example, is because we won't be able to be sure about the ordering of effects in such cases:
Although we're not sure such methods would occur in real code, we also considered the behavior of an API like the following:
namespaceSystem{publicstaticclassFile{publicstaticasyncTask<bool>ExistsAsync([NotNull]string?path);// throws an exception if path is null}}publicclassC{publicstaticasyncTaskM(string?path){if(awaitFile.ExistsAsync(path)){path.ToString();// ok}}}
We could strictly define the behavior such that postconditions which "worsen" the current state such as [MaybeNull] are applied whether the method is awaited or not, to represent the possibility that the state change has occurred. However, this is less relevant for async code, where by-ref parameters are not permitted. In this case, the only use case for such postcondition attributes on parameters is for "assert" scenarios, learning information about the input without changing it, such as the ExistsAsync example above.
MemberNotNullWhen
This may also be an opportunity to define a useful relationship between Task<bool> and MemberNotNullWhen(bool), where we understand the attribute to be applying to the underlying value of the Task, rather than the Task itself, which is generally invalid/useless. There may be some additional complexity here relating to the different ways a value can be extracted out of a task-like object, so we may not be able to do it, or it may be on a best-effort basis.
If we do this, we might also want to treat ConfigureAwait specially, in order to do the same thing for both if (await c.M()) { ...} and if (await c.M().ConfigureAwait(false)) { ... }.
Summary
Assume that nullable postcondition attributes such as
MemberNotNull
only apply when Task-returning calls are directly awaited. Adjust analysis of implementations to permit postconditions to be fulfilled after anawait
boundary.Motivation
Users are requesting the ability to specify postconditions which are only fulfilled after a task-returning method is awaited. We're seeing a relatively high amount of engagement (upvotes and thumbs up) on this compared to many other issues in the nullable space.
A community member observed that there are very few usages of
[MemberNotNull]
on async methods in open source code, which bodes well for the possibility of breaking the existing behavior if we think it's the right thing for our users. We also think that the existing behavior on async methods is just broken. Currently, when an async method throws an exception, we treat it as fulfilling the postcondition, much like for a non-async method. This is because we think the caller won't actually be able to use whatever variable was expected to be not-null after the call--thus, nothing to warn about.However, in reality, when an async method throws an exception, it is packaged up into a Task and returned to the caller. It is not thrown until the task is awaited. Because pretty much any code can throw an exception, no async method is able to reliably fulfill nullability postconditions as currently designed. This is similar to an issue we recently fixed in definite assignment.
SharpLab
Detailed design
We think the most viable option to fix the issue here is to change the existing meaning of
[MemberNotNull]
, as well as other postconditions like[NotNull]
, on Task-returning methods (methods which returnawait
-able things), such that the postconditions are only applied if the call is immediately awaited. The only safe alternative would be to warn if a nullable postcondition attribute is used at all on an async method, which would be unfortunate.The requirement to change behavior at call sites for any "awaitable" method call is because
async
is treated as an implementation detail, and it's not possible to determine whether a method from metadata isasync
or not. We'd prefer to have uniformity in usage across both source and metadata.The requirement to immediately await rather than being able to store the task in a variable and await it later, for example, is because we won't be able to be sure about the ordering of effects in such cases:
Although we're not sure such methods would occur in real code, we also considered the behavior of an API like the following:
We could strictly define the behavior such that postconditions which "worsen" the current state such as
[MaybeNull]
are applied whether the method is awaited or not, to represent the possibility that the state change has occurred. However, this is less relevant for async code, where by-ref parameters are not permitted. In this case, the only use case for such postcondition attributes on parameters is for "assert" scenarios, learning information about the input without changing it, such as theExistsAsync
example above.MemberNotNullWhen
This may also be an opportunity to define a useful relationship between
Task<bool>
andMemberNotNullWhen(bool)
, where we understand the attribute to be applying to the underlying value of the Task, rather than the Task itself, which is generally invalid/useless. There may be some additional complexity here relating to the different ways a value can be extracted out of a task-like object, so we may not be able to do it, or it may be on a best-effort basis.If we do this, we might also want to treat
ConfigureAwait
specially, in order to do the same thing for bothif (await c.M()) { ...}
andif (await c.M().ConfigureAwait(false)) { ... }
.LDM Discussions
The text was updated successfully, but these errors were encountered: