Add analyzer to verify that all functionality of a class that is exposed with JTF.Run is also available via an async method.
Sample bad cases:
public void Foo(int a) {
// ...lots of code...
JoinableTaskFactory.Run(async delegate {
// lots more code.
});
// ...lots of code...
}
public void Bar(int a) {
JoinableTaskFactory.Run(() => BarAsync(a)); // calling an async method with less visibility than the sync version.
}
internal void BarAsync(int a) {
// code
}
Sample good cases:
public void Foo(int a) {
JoinableTaskFactory.Run(() => FooAsync(a));
}
public async Task FooAsync() {
await Task.Yield();
}
Proposed Spec
We seek to drive people to expose all their async code publicly so that the benefits of asynchrony is shared with all callers. JTF.Run represents an end to the benefits of asynchronous methods and thus should only be used where necessary. For example, when implementing an interface already constrained to be synchronous. But such functionality should also be exposed directly as async without the JTF.Run wrapper. Thus the best body for a JTF.Run delegate would be to simply forward the call to the async equivalent of the method.
A simple version of this analyzer then may require that a Foo method do nothing but call FooAsync inside its JTF.Run delegate and return the result (if applicable). While this would seem to work most of the time, it would misfire when the older synchronous API contains out parameters, or otherwise must shim inputs or outputs in some way. For example consider that a legacy interface may exist that looks like this:
public interface IFoo {
int GetName(out string name);
}
Later we realize we want to expose an async version of this. We also want to make it more user-friendly, so we return the string instead of an HRESULT:
public interface IFoo2 {
Task<string> GetNameAsync();
}
Now the implementation should ideally be written like this:
public class Foo : IFoo, IFoo2 {
public async Task<string> GetNameAsync() {
await Task.Yield();
return "Andrew";
}
public int GetName(out string name) {
try {
name = JoinableTaskFactory.Run(() => GetNameAsync());
return 0;
} catch (Exception ex) {
return Marshal.GetHRForException(ex);
}
}
}
You can see how more code than just a direct JTF.Run is necessary for the legacy method to maintain backward compatibility even though all the functionality is exposed asynchronously. But we also observe that the entirety of this shim method could be implemented by the caller (no non-public types or members are accessed) so this is how we might detect that it's allowable.
Proposed rules for creating a diagnostic:
- Only consider public properties and methods of public types. VSSDK009 will take care of the non-public methods.
- Any method with JTF.Run in it can only invoke or access members that are also public, both inside and outside the delegate passed to JTF.Run. Any access or invocation of a non-public member represents work that cannot be done asynchronously by the caller.
Consider:
- If the public method that uses JTF.Run implements an interface (either explicitly or implicitly) then all members it accesses must also implement an interface.
Alternative spec
Whenever a non-Task returning method Name() uses JTF.Run, create a warning if a NameAsync() method does not also exist.
Pros:
Cons:
- Does not defend against Name() containing unique functionality that is not exposed in NameAsync().