Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions doc/UNT0045.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# UNT0045 Avoid async void methods

An `async void` method cannot be awaited, and its exceptions are not propagated to its caller through a task. An ordinary asynchronous method should return a task-like type so callers can observe completion and failures.

## Examples of patterns that are flagged by this analyzer

```csharp
using UnityEngine;

class Loader : MonoBehaviour
{
public async void LoadAsync()
{
await Awaitable.NextFrameAsync();
}
}
```

## How to fix

Return the asynchronous type appropriate for the project, such as `Awaitable`, `UniTask`, `Task`, or `ValueTask`, and update callers to await the operation.

```csharp
using UnityEngine;

class Loader : MonoBehaviour
{
public async Awaitable LoadAsync()
{
await Awaitable.NextFrameAsync();
}
}
```

The analyzer checks both methods and local functions. It leaves recognized Unity message callbacks, Unity load/menu callbacks, overrides, interface implementations, and conventional `(object, EventArgs)` event handlers alone because their contracts can require `void`. Derived event-argument types are supported. These callbacks still need appropriate exception handling.

A method merely named `Start` is not necessarily a Unity callback: its declaring type, static/instance form, and signature must match. Local functions are never Unity messages.

Task-like return types and the explicit fire-and-forget `UniTaskVoid` return type are not flagged. `UniTaskVoid` is not an awaitable replacement when the caller needs to observe completion.

No automatic code fix is provided because changing a return type requires reviewing callers and callback contracts.
39 changes: 39 additions & 0 deletions doc/UNT0046.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# UNT0046 Avoid async void delegates

An asynchronous lambda or anonymous method can silently become `async void` when converted to `Action`, `UnityAction`, or another void-returning delegate. The receiver cannot await its completion or observe its exceptions through a task.

## Examples of patterns that are flagged by this analyzer

```csharp
using System;
using UnityEngine;

Action callback = async () =>
{
await Awaitable.NextFrameAsync();
};
```

The analyzer also checks simple lambdas, expression-bodied lambdas, anonymous `delegate` expressions, callback arguments, and event subscriptions. It uses the resolved delegate signature, not just the presence of `async`.

## How to fix

Use a task-returning delegate when the receiving API supports one.

```csharp
using System;
using UnityEngine;

Func<Awaitable> callback = async () =>
{
await Awaitable.NextFrameAsync();
};

await callback();
```

Delegates returning `Task`, `ValueTask`, `UniTask`, `Awaitable`, or their generic forms are not flagged. For example, an async lambda passed to the task-returning overload of `Task.Run` is not an `async void` delegate. An explicit `UniTaskVoid` delegate is also left alone.

When an event or Unity API requires a void-returning callback, provide an intentional exception-handling/fire-and-forget boundary rather than assuming the receiver will await an async lambda. Do not block the Unity main thread to make an asynchronous callback appear synchronous.

No automatic code fix is provided because the receiving API determines which delegate signatures are valid.
35 changes: 35 additions & 0 deletions doc/UNT0047.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# UNT0047 Do not convert task-like values to strings

Formatting a task-like value does not await it or retrieve its asynchronous result. This often indicates a missing `await`.

The analyzer recognizes `Task`, `ValueTask`, `UniTask`, and Unity `Awaitable`, including generic forms and task-derived types. It reuses the type-matching infrastructure used by the Unity message analyzers, without treating message-specific `UniTaskVoid` as an awaitable task.

## Examples of patterns that are flagged by this analyzer

```csharp
Awaitable<int> score = LoadScoreAsync();
Debug.Log($"Score: {score}");
```

Other recognized string conversions include:

- String concatenation with `+` or `+=`, and parameterless `ToString()`.
- Direct task arguments to `string.Format`, `string.Concat`, `StringBuilder.Append`, and `StringBuilder.AppendFormat`.
- Direct task arguments to `Console.Write`, `Console.WriteLine`, and Unity `Debug.Log`, `LogWarning`, `LogError`, and their `LogFormat` variants.

Directly constructed formatting argument arrays are inspected too. The analyzer does not track task values stored in `object` variables or previously constructed arrays, and it leaves explicit conversions to `object` and user-defined concatenation operators alone.

## How to fix

Await the operation and format the result.

```csharp
int score = await LoadScoreAsync();
Debug.Log($"Score: {score}");
```

If the task has no result, await it separately and log a meaningful message instead. If logging the operation itself is intentional, explicitly select its metadata, such as a .NET task's `Status`.

Unrelated methods accepting `object` are not assumed to perform string conversion. Already-awaited results and unrelated types that happen to be named `Task`, `UniTask`, or `Awaitable` are not flagged.

No automatic code fix is provided: inserting `await` can change execution order and require changes to the containing method's signature.
41 changes: 41 additions & 0 deletions doc/UNT0048.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# UNT0048 Do not return null for task-like types

Awaiting a null `Task` or Unity `Awaitable` throws instead of completing normally. A synchronous method that supplies an asynchronous operation should return a non-null task-like value.

## Examples of patterns that are flagged by this analyzer

```csharp
using UnityEngine;

Awaitable WaitIfNeeded(bool shouldWait)
{
if (!shouldWait)
return null;

return Awaitable.NextFrameAsync();
}
```

The analyzer checks return statements and expression bodies in methods, local functions, anonymous functions, properties, and indexers. It recognizes null constants, reference-type `default` values, null conditional/switch branches, null coalescing fallbacks, and conditional access. Nullable annotations do not make a null operation safe to await.

## How to fix

Return a non-null operation. One option is an async method that completes normally on the no-work path.

```csharp
using UnityEngine;

async Awaitable WaitIfNeeded(bool shouldWait)
{
if (shouldWait)
await Awaitable.NextFrameAsync();
}
```

For .NET tasks, `Task.CompletedTask` and `Task.FromResult<T>(result)` can represent completed operations when appropriate.

An **async result** can legitimately be null: `return null` in an `async Awaitable<string>` or `async Task<string>` method supplies the result, not the operation. These returns are not flagged. Nested lambdas and local functions are analyzed according to their own async contracts.

`UniTask` and `ValueTask` are value types. Their default values are not null task references and are not flagged. The analyzer does not perform general null-state/data-flow analysis of variables or other method calls.

No automatic code fix is provided because the correct result and completion behavior depend on the operation's contract.
4 changes: 4 additions & 0 deletions doc/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ ID | Title | Category
[UNT0042](UNT0042.md) | `Mesh` array property accessed in loop | Performance
[UNT0043](UNT0043.md) | Possible typo in conditional compilation symbol | Correctness
[UNT0044](UNT0044.md) | Avoid temporary strings when setting TextMeshPro text | Performance
[UNT0045](UNT0045.md) | Avoid async void methods | Correctness
[UNT0046](UNT0046.md) | Avoid async void delegates | Correctness
[UNT0047](UNT0047.md) | Do not convert task-like values to strings | Correctness
[UNT0048](UNT0048.md) | Do not return null for task-like types | Correctness

# Diagnostic Suppressors

Expand Down
133 changes: 133 additions & 0 deletions src/Microsoft.Unity.Analyzers.Tests/AsyncVoidDelegateTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*--------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See LICENSE in the project root for license information.
*-------------------------------------------------------------------------------------------*/

using System;
using System.Threading.Tasks;
using Xunit;

namespace Microsoft.Unity.Analyzers.Tests;

public class AsyncVoidDelegateTests : BaseDiagnosticVerifierTest<AsyncVoidDelegateAnalyzer>
{
[Theory]
[InlineData("Action callback = async () => { await Awaitable.NextFrameAsync(); }; callback();")]
[InlineData("Action<int> callback = async value => { await Task.Yield(); }; callback(1);")]
[InlineData("Action callback = async delegate { await Task.Yield(); }; callback();")]
[InlineData("Action<int> callback = async delegate(int value) { await Task.Yield(); }; callback(1);")]
[InlineData("Action callback = async () => await Task.Delay(1); callback();")]
[InlineData("Action callback = async () => { await default(UniTask); }; callback();")]
[InlineData("Action callback = async () => { await default(UniTask<int>); }; callback();")]
[InlineData("UnityEngine.Events.UnityAction callback = async () => { await Task.Yield(); }; callback();")]
[InlineData("EventHandler callback = async (sender, args) => { await Task.Yield(); }; callback(null, EventArgs.Empty);")]
[InlineData("Register(async () => { await Task.Yield(); });")]
[InlineData("Register((Action)(async () => { await Task.Yield(); }));")]
public async Task VoidReturningDelegates(string statement)
{
await VerifyCSharpDiagnosticAsync(AsyncTestSources.Context, CreateSource(statement),
ExpectDiagnostic().WithLocation(11, 9 + statement.IndexOf("async", StringComparison.Ordinal)));
}

[Theory]
[InlineData("Task", "")]
[InlineData("Task<int>", "return 1;")]
[InlineData("ValueTask", "")]
[InlineData("ValueTask<int>", "return 1;")]
[InlineData("Awaitable", "")]
[InlineData("Awaitable<int>", "return 1;")]
[InlineData("UniTask", "")]
[InlineData("UniTask<int>", "return 1;")]
[InlineData("UniTaskVoid", "")]
public async Task TaskReturningDelegates(string type, string result)
{
await VerifyCSharpDiagnosticAsync(AsyncTestSources.Context,
CreateSource($"Func<{type}> callback = async () => {{ await Task.Yield(); {result} }}; _ = callback();"));
}

[Theory]
[InlineData("Action callback = () => { }; callback();")]
[InlineData("Action callback = delegate { }; callback();")]
[InlineData("_ = Task.Run(async () => { await Task.Yield(); });")]
[InlineData("Func<Awaitable> callback = () => Awaitable.NextFrameAsync(); _ = callback();")]
[InlineData("Func<UniTask> callback = () => default; _ = callback();")]
public async Task NoAsyncVoidConversion(string statement)
{
await VerifyCSharpDiagnosticAsync(AsyncTestSources.Context, CreateSource(statement));
}

[Fact]
public async Task EventSubscription()
{
const string source = @"
using System;
using System.Threading.Tasks;

class Example
{
public event Action Changed;
public void Configure()
{
Changed += async () => { await Task.Yield(); };
Changed?.Invoke();
}
}
";

await VerifyCSharpDiagnosticAsync(source, ExpectDiagnostic().WithLocation(10, 20));
}

[Fact]
public async Task NestedAsyncVoidDelegate()
{
var source = CreateSource(@"_ = Task.Run(async () =>
{
Action callback = async () => { await Task.Yield(); };
callback();
await Task.Yield();
});");

await VerifyCSharpDiagnosticAsync(AsyncTestSources.Context, source,
ExpectDiagnostic().WithLocation(13, 31));
}

[Fact]
public async Task MethodGroupIsNotAnAnonymousFunction()
{
const string source = @"
using System;
using System.Threading.Tasks;

class Example
{
public void Configure()
{
Action callback = Run;
callback();
}
private async void Run() { await Task.Yield(); }
}
";

await VerifyCSharpDiagnosticAsync(source);
}

private static string CreateSource(string statement)
{
return $@"
using System;
using System.Threading.Tasks;
using UnityEngine;
using Cysharp.Threading.Tasks;

class Example
{{
public void Configure()
{{
{statement}
}}
private void Register(Action callback) {{ callback(); }}
}}
" + AsyncTestSources.UniTaskTypes;
}
}
Loading