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
186 changes: 186 additions & 0 deletions src/dotnet/skills/refactoring-to-async/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
---
name: refactoring-to-async
description: Convert synchronous .NET code to async/await, including proper Task propagation, cancellation support, and avoiding common async anti-patterns. Use when converting blocking I/O calls to async, fixing thread pool starvation, or modernizing sync-over-async code.
---

# Refactoring to Async

## When to Use

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

move any part of use/not use into decription similar to
https://github.com/dotnet/runtime/pull/125005/changes#diff-ded9450821c1df27638def6250c00784c7f795e3a9c56ad13d09a34853a0d09bR3-R4
if it can avoid unnecessaryt reloading.

Possibly not all can go there. For example: user wants to parallelize work is something that can be known before loading the skill, and avoid load. Whereas possibly "code is CPU bound" might need loading the skill? but in general I think ?all of this should move up there.


- Converting synchronous I/O-bound code to async/await
- Fixing thread pool starvation caused by blocking calls
- Modernizing legacy `.Result` / `.Wait()` / `.GetAwaiter().GetResult()` patterns
- Adding `CancellationToken` support to async call chains

## When Not to Use

- The code is CPU-bound (async won't help; consider `Parallel.For` or `Task.Run`)
- The synchronous code has no I/O operations
- The user wants to parallelize work, not make it async

## Inputs

| Input | Required | Description |
|-------|----------|-------------|
| Code to refactor | Yes | The synchronous methods to convert |
| Scope | No | Single method, class, or full call chain |

## Workflow

### Step 1: Identify blocking I/O calls

Search for synchronous I/O patterns in the codebase:

```bash
grep -rn "\.Result\b\|\.Wait()\|\.GetAwaiter()\.GetResult()\|ReadToEnd()\|\.Read()\|\.Write(" --include="*.cs" .

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

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

The grep pattern uses \b for a word-boundary, but grep's default regex syntax does not treat \b as a word boundary (it’s typically interpreted as a backspace). Also, \.Read() only matches parameterless Read() calls and will miss the common Read(...) overloads. Consider switching to rg (ripgrep) or grep -P/grep -E with a corrected pattern (e.g., matching \.Read\( and \.Write\(, and using a portable word boundary like \<Result\> or PCRE \b with -P).

Suggested change
grep -rn "\.Result\b\|\.Wait()\|\.GetAwaiter()\.GetResult()\|ReadToEnd()\|\.Read()\|\.Write(" --include="*.cs" .
grep -rnP "\.Result\b|\.Wait\(\)|\.GetAwaiter\(\)\.GetResult\(\)|ReadToEnd\(\)|\.Read\(|\.Write\(" --include="*.cs" .

Copilot uses AI. Check for mistakes.
```

Common blocking patterns to convert:

| Synchronous | Async Replacement |
|---|---|
| `stream.Read(buffer)` | `await stream.ReadAsync(buffer, ct)` |
| `stream.Write(data)` | `await stream.WriteAsync(data, ct)` |
| `reader.ReadToEnd()` | `await reader.ReadToEndAsync(ct)` |
| `File.ReadAllText(path)` | `await File.ReadAllTextAsync(path, ct)` |
| `File.WriteAllBytes(...)` | `await File.WriteAllBytesAsync(..., ct)` |
| `client.Send(request)` | `await client.SendAsync(request, ct)` |
| `connection.Open()` | `await connection.OpenAsync(ct)` |
| `command.ExecuteReader()` | `await command.ExecuteReaderAsync(ct)` |
| `Thread.Sleep(ms)` | `await Task.Delay(ms, ct)` |
| `task.Result` | `await task` |
| `task.Wait()` | `await task` |

@danmoseley danmoseley Mar 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
| `reader.Read()` (DbDataReader) | `await reader.ReadAsync()` |
| `command.ExecuteNonQuery()` (DbCommand) | `await command.ExecuteNonQueryAsync()` |

maybe

### Step 2: Convert bottom-up

Start from the lowest-level I/O calls and work upward through the call chain. This avoids sync-over-async wrappers.

**Before:**

```csharp
public string GetUserData(int userId)
{
var response = _httpClient.Send(new HttpRequestMessage(HttpMethod.Get, $"/users/{userId}"));
var body = new StreamReader(response.Content.ReadAsStream()).ReadToEnd();
return body;
}
```

**After:**

```csharp
public async Task<string> GetUserDataAsync(int userId, CancellationToken ct = default)
{
var response = await _httpClient.GetAsync($"/users/{userId}", ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(ct);
}
```

### Step 3: Propagate async through the call chain

Every caller of an async method must also become async. Follow the chain upward:

```csharp
// Layer 1: Data access (already converted)
public async Task<User> GetUserAsync(int id, CancellationToken ct) { ... }

// Layer 2: Business logic (convert next)
public async Task<UserDto> GetUserProfileAsync(int id, CancellationToken ct)
{
var user = await GetUserAsync(id, ct);
return MapToDto(user); // sync mapping is fine
}

// Layer 3: API endpoint (convert last)
app.MapGet("/users/{id}", async (int id, CancellationToken ct, IUserService svc) =>
await svc.GetUserProfileAsync(id, ct));
```

### Step 4: Add CancellationToken support

Accept `CancellationToken` as the last parameter in every async method and pass it through:

```csharp
public async Task<List<Order>> GetOrdersAsync(
int userId,
CancellationToken ct = default) // Always provide a default
{
var response = await _client.GetAsync($"/orders?user={userId}", ct);
var json = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<List<Order>>(json);
}
```

ASP.NET Core automatically supplies a `CancellationToken` that fires when the client disconnects.

### Step 5: Update interfaces

```csharp
// Before
public interface IUserRepository
{
User GetById(int id);
List<User> GetAll();
}

// After
public interface IUserRepository
{
Task<User> GetByIdAsync(int id, CancellationToken ct = default);
Task<List<User>> GetAllAsync(CancellationToken ct = default);
}
```

### Step 6: Build and fix

```bash
dotnet build
```

Common errors after async refactoring:

| Error | Fix |
|---|---|
| `CS4032`: `await` in non-async method | Add `async` to the method signature and return `Task` or `Task<T>` |
| `CS0029`: Cannot convert `Task<T>` to `T` | Add `await` before the call |
| `CS0127`: Method returns `Task` but body returns value | Change return type to `Task<T>` |
| `CS1998`: Async method lacks `await` | Remove `async` if no awaits are needed, or the method is genuinely sync |
Comment on lines +143 to +148

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
| Error | Fix |
|---|---|
| `CS4032`: `await` in non-async method | Add `async` to the method signature and return `Task` or `Task<T>` |
| `CS0029`: Cannot convert `Task<T>` to `T` | Add `await` before the call |
| `CS0127`: Method returns `Task` but body returns value | Change return type to `Task<T>` |
| `CS1998`: Async method lacks `await` | Remove `async` if no awaits are needed, or the method is genuinely sync |
| Error | Fix |
|---|---|
Error | Fix |
|---|---|
| `CS4032`: `await` in non-async method | Add `async` to the method signature and return `Task` or `Task<T>` |
| `CS0029`: Cannot convert `Task<T>` to `T` | Add `await` before the call |
| `CS0127`: Method returns `Task` but body returns value | Change return type to `Task<T>` |
| `CS1983`: Return type of async method must be void, Task, Task<T>, ValueTask, or ValueTask<T> | Wrap the return type: `async string Foo()``async Task<string> Foo()` |
| `CS1998`: Async method lacks `await` | Remove `async` if no awaits are needed, or the method is genuinely sync |
| `CS0535`: Does not implement interface member | Update the interface to match the new async signatures (or vice versa) |
| `CS7036`: No argument given for required parameter `cancellationToken` | Add `ct` argument at call sites after adding `CancellationToken` to signatures |
| `CS1503`: Argument type mismatch (`Task<T>` passed where `T` expected) | Add `await` at the call site |

if you want to add more?


### Step 7: Verify no anti-patterns remain

Search for remaining issues:

```bash
grep -rn "\.Result\b\|\.Wait()\|\.GetAwaiter()\.GetResult()" --include="*.cs" .

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

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

Same issue as Step 1: grep without -P won't treat \b as a word boundary, so this command may not reliably find .Result usages. Adjust the command to use a regex flavor that supports word boundaries (or use \</\>), so the "should return zero results" guidance is accurate.

Suggested change
grep -rn "\.Result\b\|\.Wait()\|\.GetAwaiter()\.GetResult()" --include="*.cs" .
grep -rnP '\.Result\b|\.Wait\(\)|\.GetAwaiter\(\)\.GetResult\(\)' --include="*.cs" .

Copilot uses AI. Check for mistakes.
```

This should return zero results in the refactored code paths.

## Anti-Patterns to Avoid

| Anti-Pattern | Problem | Correct Approach |
|---|---|---|
| `task.Result` or `task.Wait()` | Blocks thread, risks deadlock | `await task` |
| `async void` methods | Exceptions crash the process | `async Task` (except event handlers) |
| `Task.Run` wrapping async I/O | Wastes a thread pool thread | Call async method directly |
| Missing `ConfigureAwait(false)` in libraries | Can deadlock in UI/ASP.NET sync contexts | Add `ConfigureAwait(false)` in library code |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this is missing from all the examples. maybe indicate those are app code?

It might be worth mentioning as instructions, not just in anti-patterns -- if code is library add .ConfigureAwait(false) to every await. It should be used consistently or not at all.

| Fire-and-forget without error handling | Swallows exceptions silently | `await` or use `_ = Task.Run(async () => { try... })` |

## Validation

- [ ] `dotnet build` compiles without errors
- [ ] No remaining `.Result`, `.Wait()`, or `.GetAwaiter().GetResult()` in converted code
- [ ] `CancellationToken` is propagated through the full call chain
- [ ] `dotnet test` passes (existing tests updated for async)
- [ ] No `async void` methods (except UI event handlers)

## Common Pitfalls

| Pitfall | Solution |
|---------|----------|
| Deadlock after conversion | Ensure `await` is used everywhere; no `.Result` mixed with `await` |
| Performance worse after conversion | Async adds overhead for CPU-bound work; only use for I/O |
| Forgetting to update tests | Test methods must return `Task` and use `await` |
| Breaking interface consumers | Consider keeping sync wrappers temporarily during staged migration |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this may be a big high level/conceptual for this skill?

| `ValueTask` vs `Task` confusion | Use `Task` by default; `ValueTask` only for hot-path methods that frequently return synchronously |
14 changes: 14 additions & 0 deletions src/dotnet/tests/refactoring-to-async/SyncService.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.0" />
</ItemGroup>

</Project>
115 changes: 115 additions & 0 deletions src/dotnet/tests/refactoring-to-async/UserService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using Microsoft.Data.SqlClient;

namespace SyncService;

public interface IUserRepository
{
User GetById(int id);
List<User> GetAll();
void Save(User user);
}

public class UserRepository : IUserRepository
{
private readonly string _connectionString;
private readonly HttpClient _httpClient;

public UserRepository(string connectionString, HttpClient httpClient)
{
_connectionString = connectionString;
_httpClient = httpClient;
}

public User GetById(int id)
{
using var connection = new SqlConnection(_connectionString);
connection.Open();
using var command = new SqlCommand("SELECT Id, Name, Email FROM Users WHERE Id = @Id", connection);
command.Parameters.AddWithValue("@Id", id);
using var reader = command.ExecuteReader();
if (reader.Read())
{
return new User
{
Id = reader.GetInt32(0),
Name = reader.GetString(1),
Email = reader.GetString(2)
};
}
throw new InvalidOperationException($"User {id} not found");
}

public List<User> GetAll()
{
using var connection = new SqlConnection(_connectionString);
connection.Open();
using var command = new SqlCommand("SELECT Id, Name, Email FROM Users", connection);
using var reader = command.ExecuteReader();
var users = new List<User>();
while (reader.Read())
{
users.Add(new User
{
Id = reader.GetInt32(0),
Name = reader.GetString(1),
Email = reader.GetString(2)
});
}
return users;
}

public void Save(User user)
{
using var connection = new SqlConnection(_connectionString);
connection.Open();
using var command = new SqlCommand(
"INSERT INTO Users (Name, Email) VALUES (@Name, @Email)", connection);
command.Parameters.AddWithValue("@Name", user.Name);
command.Parameters.AddWithValue("@Email", user.Email);
command.ExecuteNonQuery();
}
}

public class UserService
{
private readonly IUserRepository _repo;
private readonly HttpClient _httpClient;

public UserService(IUserRepository repo, HttpClient httpClient)
{
_repo = repo;
_httpClient = httpClient;
}

public User GetUserProfile(int userId)
{
var user = _repo.GetById(userId);

// Sync-over-async: blocking call
var response = _httpClient.Send(new HttpRequestMessage(HttpMethod.Get, $"/api/avatars/{userId}"));
var avatarUrl = new StreamReader(response.Content.ReadAsStream()).ReadToEnd();
user.AvatarUrl = avatarUrl;

return user;
}

public List<User> GetAllUsers()
{
var users = _repo.GetAll();
foreach (var user in users)
{
// Blocking call inside a loop
var task = _httpClient.GetStringAsync($"/api/avatars/{user.Id}");
user.AvatarUrl = task.Result; // Anti-pattern: .Result blocks the thread
}
return users;
}
}

public class User
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public string? AvatarUrl { get; set; }
}
33 changes: 33 additions & 0 deletions src/dotnet/tests/refactoring-to-async/eval.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
scenarios:
- name: "Refactor synchronous service to async"
prompt: "I have a service class with several synchronous database and HTTP calls. It's causing thread pool starvation under load. Can you convert it to async/await?"
setup:
copy_test_files: true
assertions:
- type: "output_matches"
pattern: "(async|await|Task<)"
- type: "output_matches"
pattern: "(CancellationToken|cancellation)"
- type: "output_not_matches"
pattern: "\\.Result\\b|\\.Wait\\(\\)"

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

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

The scenario requires identifying sync-over-async patterns like .Result/.Wait() (rubric line 14), but the output_not_matches assertion fails the run if the agent output contains .Result or .Wait() anywhere (including when calling them out as anti-patterns). This creates false negatives and makes the rubric effectively incompatible with the assertions. Consider tightening the regex to only match actual code usage (e.g., requiring a trailing semicolon) or removing this assertion and relying on other checks.

Suggested change
pattern: "\\.Result\\b|\\.Wait\\(\\)"
pattern: "\\.Result\\b\\s*;|\\.Wait\\(\\)\\s*;"

Copilot uses AI. Check for mistakes.
rubric:
- "Identified all synchronous blocking I/O patterns (.Result, .Wait(), synchronous Read/Write calls)"
- "Converted methods bottom-up starting from the lowest-level I/O calls"
- "Changed method signatures to return Task or Task<T> with Async suffix"
- "Added CancellationToken parameter propagation throughout the call chain"
- "Updated interfaces to match the async method signatures"
- "Did not introduce async void methods (except event handlers)"
- "Verified the code compiles after conversion with dotnet build"
expect_tools: ["bash"]
timeout: 120

- name: "Async refactoring should not apply to CPU-bound code"
prompt: "I have a method that does heavy matrix multiplication using nested for loops. Can you make it faster?"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

is this really something the user would write? seems like a gimme as written. more likely the user would say "make DoIt() async so it's faster" and the AI would have to figure out that it's CPU bound?

assertions:
- type: "output_not_matches"
pattern: "(async Task|await.*ReadAsync|await.*WriteAsync)"
rubric:
- "Did NOT suggest converting CPU-bound computation to async/await"
- "Suggested parallelism approaches (Parallel.For, Task.Run, SIMD, or algorithmic optimization)"
- "Correctly identified that async is for I/O-bound work, not CPU-bound work"
timeout: 60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

do you expect ConfigureAwait(false) in this UserService case? either way, should be a test for the opposite case (eg winforms) and verify in each case it's present or not as expected

Loading