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
234 changes: 234 additions & 0 deletions plugins/aspnetcore/skills/implementing-websocket-endpoints/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
---
name: implementing-websocket-endpoints
description: >
Implement raw WebSocket endpoints in ASP.NET Core 8+ using the built-in middleware.
USE FOR: real-time bidirectional communication (chat, live updates, gaming), WebSocket
receive/send loops, AcceptWebSocketAsync, UseWebSockets middleware, connection lifecycle,
broadcasting to multiple clients, WebSocket authentication.
DO NOT USE FOR: server-to-client only streaming (use SSE or TypedResults.ServerSentEvents),
apps needing automatic reconnection and hub abstraction (use SignalR), simple request/response (use HTTP).
---

# Implementing WebSocket Endpoints in ASP.NET Core

## Inputs

| Input | Required | Description |
|-------|----------|-------------|
| WebSocket path | Yes | URL path for WebSocket endpoint |
| Message format | No | Text (JSON) or binary |
| Connection management | No | How to track connected clients |

## Workflow

### Step 1: CRITICAL — There is no `MapWebSocket()` method

```csharp
// COMMON MISTAKE: Developers look for a MapWebSocket method.
// It does NOT exist in ASP.NET Core.

// WRONG — these don't exist:
app.MapWebSocket("/ws", handler); // ❌ NOT a real method
app.MapGet("/ws").UseWebSocket(); // ❌ NOT a real method

// CORRECT — WebSocket is middleware-based, not endpoint-routing:
app.UseWebSockets(); // ← Register the middleware

// Then handle WebSocket requests in regular middleware or endpoints:
app.Map("/ws", async (HttpContext context) =>
Comment on lines +31 to +38

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

The examples use /ws as the WebSocket path (Step 1/Step 5), but the eval scenario and opening requirements describe /ws/chat. To avoid confusion and copy/paste errors, consider making the path consistent throughout the skill (either update the examples to /ws/chat or make the eval prompt generic).

Suggested change
app.MapWebSocket("/ws", handler); // ❌ NOT a real method
app.MapGet("/ws").UseWebSocket(); // ❌ NOT a real method
// CORRECT — WebSocket is middleware-based, not endpoint-routing:
app.UseWebSockets(); // ← Register the middleware
// Then handle WebSocket requests in regular middleware or endpoints:
app.Map("/ws", async (HttpContext context) =>
app.MapWebSocket("/ws/chat", handler); // ❌ NOT a real method
app.MapGet("/ws/chat").UseWebSocket(); // ❌ NOT a real method
// CORRECT — WebSocket is middleware-based, not endpoint-routing:
app.UseWebSockets(); // ← Register the middleware
// Then handle WebSocket requests in regular middleware or endpoints:
app.Map("/ws/chat", async (HttpContext context) =>

Copilot uses AI. Check for mistakes.
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}

using var ws = await context.WebSockets.AcceptWebSocketAsync();
await HandleWebSocketAsync(ws, context.RequestAborted);
});
```

### Step 2: Configure WebSocket middleware

```csharp
var app = builder.Build();

app.UseWebSockets(new WebSocketOptions
{
// CRITICAL: KeepAliveInterval sends ping frames to keep connection alive
// Default is 2 minutes. Set to match your infrastructure timeouts.
KeepAliveInterval = TimeSpan.FromSeconds(30),

// KeepAliveTimeout: how long to wait for pong response before closing
// Set this to detect dead connections faster
KeepAliveTimeout = TimeSpan.FromSeconds(15),

// Allowed origins (for browser CORS protection)
// CRITICAL: Without explicit AllowedOrigins, ANY website can open a WebSocket to your API
AllowedOrigins = { "https://myapp.com", "https://www.myapp.com" }
});

// CRITICAL ORDERING: UseWebSockets MUST come before the endpoint that handles WebSockets
app.UseRouting();
app.UseAuthorization();
// WebSocket handling endpoint comes after routing
Comment on lines +56 to +74

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

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

The Step 2 code block registers UseWebSockets twice, which is misleading and incorrect. Lines 71–80 call app.UseWebSockets(new WebSocketOptions { ... }) with the configured options (the intended "CORRECT" usage), and then line 83 calls app.UseWebSockets() again (without any options) just to illustrate ordering. These two calls would both register the middleware in sequence, with the second one overriding the first's configuration. The ordering comment and the configuration example should be combined into a single app.UseWebSockets(new WebSocketOptions { ... }) call placed before UseRouting() and UseAuthorization() — not separated into two UseWebSockets() calls.

Copilot uses AI. Check for mistakes.
```

### Step 3: Implement the echo/receive loop

```csharp
static async Task HandleWebSocketAsync(WebSocket webSocket, CancellationToken ct)
{
var buffer = new byte[4096];

// CRITICAL: The receive loop pattern
// ReceiveAsync returns when a message (or close) is received
var result = await webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer), ct);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shouldn't buffer.AsMemory() used instead?


while (!result.CloseStatus.HasValue)
{
if (result.MessageType == WebSocketMessageType.Text)
{
// CRITICAL: For large messages, EndOfMessage may be false
// You MUST accumulate fragments until EndOfMessage == true
if (!result.EndOfMessage)
{
// Accumulate into a MemoryStream or larger buffer
// Do NOT process until EndOfMessage == true
result = await webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer), ct);
continue;
}

var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
Comment on lines +95 to +104

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

The text-message fragmentation handling is incorrect: when EndOfMessage is false, the code receives the next fragment but discards the previously received bytes, so large messages will be truncated. Update the sample to actually accumulate fragments (e.g., append result.Count bytes from each fragment into a buffer/MemoryStream) and only decode/deserialise once EndOfMessage is true.

Suggested change
if (!result.EndOfMessage)
{
// Accumulate into a MemoryStream or larger buffer
// Do NOT process until EndOfMessage == true
result = await webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer), ct);
continue;
}
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
using var messageBuffer = new System.IO.MemoryStream();
// Write the first fragment
messageBuffer.Write(buffer, 0, result.Count);
// If the message is fragmented, keep reading until EndOfMessage == true
while (!result.EndOfMessage)
{
result = await webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer), ct);
if (result.MessageType != WebSocketMessageType.Text)
{
// Unexpected change of message type; break out to avoid corrupting the buffer
break;
}
messageBuffer.Write(buffer, 0, result.Count);
}
var messageBytes = messageBuffer.ToArray();
var message = Encoding.UTF8.GetString(messageBytes, 0, messageBytes.Length);

Copilot uses AI. Check for mistakes.

// Echo back (or process the message)
var responseBytes = Encoding.UTF8.GetBytes($"Echo: {message}");
await webSocket.SendAsync(
new ArraySegment<byte>(responseBytes),
WebSocketMessageType.Text,
endOfMessage: true, // ← MUST set this for the last (or only) fragment
ct);
Comment on lines +103 to +112

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

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

The receive loop has a logic error around fragmented messages. The code checks if (!result.EndOfMessage) and the comment says "Don't process partial messages!", but the block is empty and execution falls through to the SendAsync echo on lines 116–121 unconditionally — including when EndOfMessage is false. A partial message will be echoed immediately instead of being accumulated. The code should use else (or a continue/return inside the if block) so that echoing only happens after a complete message is assembled. This is especially ironic given that the comment explicitly calls this a "CRITICAL" pitfall to avoid.

Suggested change
// Echo back (or process the message)
var responseBytes = Encoding.UTF8.GetBytes($"Echo: {message}");
await webSocket.SendAsync(
new ArraySegment<byte>(responseBytes),
WebSocketMessageType.Text,
endOfMessage: true, // ← MUST set this for the last (or only) fragment
ct);
else
{
// Echo back (or process the message) once the full message is received
var responseBytes = Encoding.UTF8.GetBytes($"Echo: {message}");
await webSocket.SendAsync(
new ArraySegment<byte>(responseBytes),
WebSocketMessageType.Text,
endOfMessage: true, // ← MUST set this for the last (or only) fragment
ct);
}

Copilot uses AI. Check for mistakes.
}
else if (result.MessageType == WebSocketMessageType.Binary)
{
// Handle binary messages — echo back or process as needed
await webSocket.SendAsync(
new ArraySegment<byte>(buffer, 0, result.Count),
WebSocketMessageType.Binary,
endOfMessage: result.EndOfMessage,
ct);
}

result = await webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer), ct);
}

// CRITICAL: Properly close the WebSocket
// You MUST respond to a close with CloseOutputAsync, NOT CloseAsync
// CloseAsync = send close + wait for response (use when YOU initiate close)
// CloseOutputAsync = respond to close (use when CLIENT initiated close)
await webSocket.CloseOutputAsync(
result.CloseStatus.Value,
result.CloseStatusDescription,
ct);
}
```

### Step 4: Broadcasting to multiple clients

```csharp
// Thread-safe connection manager
public class WebSocketConnectionManager
{
// CRITICAL: Use ConcurrentDictionary, not Dictionary
// Multiple clients connect/disconnect concurrently
private readonly ConcurrentDictionary<string, WebSocket> _connections = new();

public string AddConnection(WebSocket socket)
{
var id = Guid.NewGuid().ToString("N");
_connections.TryAdd(id, socket);
return id;
}

public void RemoveConnection(string id)
{
_connections.TryRemove(id, out _);
}

public async Task BroadcastAsync(string message, CancellationToken ct)
{
var bytes = Encoding.UTF8.GetBytes(message);
var segment = new ArraySegment<byte>(bytes);

// CRITICAL: ToList() snapshot to avoid modification during iteration
var tasks = _connections.Values
.Where(s => s.State == WebSocketState.Open)
.ToList() // ← Snapshot! Without this, concurrent disconnects cause exceptions
.Select(s => s.SendAsync(segment, WebSocketMessageType.Text, true, ct));

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

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

The comment on line 166 says "ToList() snapshot to avoid modification during iteration," but ToList() is never called. The LINQ chain .Where(...).Select(...) is deferred/lazy — Task.WhenAll materializes it, but only at execution time, not as a snapshot upfront. While ConcurrentDictionary.Values is a thread-safe snapshot in .NET, the comment is misleading because it promises a ToList() that isn't there. This creates a discrepancy between the stated intent and the actual code, which could mislead developers into thinking the pattern is safe by virtue of ToList() when it actually isn't being called. Either call .ToList() on the LINQ chain or update the comment to accurately reflect why this is safe without it.

Suggested change
.Select(s => s.SendAsync(segment, WebSocketMessageType.Text, true, ct));
.Select(s => s.SendAsync(segment, WebSocketMessageType.Text, true, ct))
.ToList();

Copilot uses AI. Check for mistakes.

// CRITICAL: Use Task.WhenAll for parallel sends
// But handle individual failures — one broken connection shouldn't kill all sends
try
{
await Task.WhenAll(tasks);
}
catch (Exception)
{
// Individual connections may have closed — clean up in the receive loop
}
}
}

// Register as singleton (shared state across all requests):
builder.Services.AddSingleton<WebSocketConnectionManager>();
```

### Step 5: Authentication with WebSockets

```csharp
// CRITICAL: The browser WebSocket API does NOT support custom HTTP headers at all.
// Unlike fetch/XMLHttpRequest, you CANNOT set Authorization headers on a WebSocket connection.
// Auth must happen via query string, cookies, or a pre-auth handshake.

// Option 1: Query string token (common for browser clients)
// ⚠️ SECURITY WARNING: Tokens in query strings may leak via server/proxy logs,
// browser history, and Referer headers. Always use wss:// (TLS) and consider
// short-lived tokens or cookie-based auth for production.
app.Map("/ws", async (HttpContext context) =>
{
var token = context.Request.Query["access_token"];
if (string.IsNullOrEmpty(token))
{
context.Response.StatusCode = 401;
return;
}

// Validate token here...

if (context.WebSockets.IsWebSocketRequest)
{
using var ws = await context.WebSockets.AcceptWebSocketAsync();
await HandleWebSocketAsync(ws, context.RequestAborted);
}
});

// Option 2: Cookie auth works naturally (cookies are sent on the HTTP upgrade request)
// Option 3: Use [Authorize] attribute if using cookie or negotiate auth
```

## Common Mistakes

1. **Looking for `MapWebSocket()`**: This method doesn't exist. WebSocket handling uses `UseWebSockets()` middleware + manual upgrade via `context.WebSockets.AcceptWebSocketAsync()`.

2. **Using `CloseAsync` instead of `CloseOutputAsync`**: When the client initiates close, respond with `CloseOutputAsync`. `CloseAsync` initiates a NEW close handshake (deadlock risk if both sides use it).

3. **Not checking `EndOfMessage`**: Large messages may arrive in fragments. Process only when `EndOfMessage == true`.

4. **Missing `AllowedOrigins`**: Without origin checking, any website can connect to your WebSocket endpoint (cross-site WebSocket hijacking).

5. **Forgetting `KeepAliveInterval`**: Load balancers and proxies close idle connections. The default 2 minutes may be too long — set to 30 seconds.

6. **Not handling concurrent broadcasts safely**: Use `ConcurrentDictionary` and snapshot collections (`.ToList()`) before iteration.
125 changes: 125 additions & 0 deletions tests/aspnetcore/implementing-websocket-endpoints/eval.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
scenarios:
- name: "Implement WebSocket chat endpoint in ASP.NET Core 8"
prompt: |
I need to add a WebSocket endpoint to my ASP.NET Core 8 API for a real-time chat feature. Requirements:

Comment on lines +1 to +5

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

The PR description/file list mentions plugins/dotnet/... and tests/dotnet/..., but this change is under plugins/aspnetcore/... and tests/aspnetcore/.... Please update the PR description (and any related docs) so reviewers and automation can correctly associate the skill with the aspnetcore plugin.

Copilot uses AI. Check for mistakes.
1. WebSocket endpoint at /ws/chat
2. Track connected clients and broadcast messages to all when one client sends
3. Handle proper connect/disconnect lifecycle
4. Authenticate users via a token in the query string (browser WebSocket API doesn't support custom headers)
5. Only allow connections from our frontend at https://myapp.com

I've been looking for something like `app.MapWebSocket("/ws/chat", handler)` but can't find it. How does WebSocket work in ASP.NET Core 8?
assertions:
- type: "output_matches"
pattern: "(UseWebSockets|WebSocketOptions)"
- type: "output_matches"
pattern: "(AcceptWebSocketAsync)"
- type: "output_matches"
pattern: "(ReceiveAsync|SendAsync)"
- type: "output_matches"
pattern: "(AllowedOrigins|Origin)"
rubric:
- "Explained that MapWebSocket does not exist in ASP.NET Core — WebSockets use UseWebSockets() middleware with manual upgrade via AcceptWebSocketAsync"
- "Configured WebSocketOptions with KeepAliveInterval and AllowedOrigins restricted to https://myapp.com for cross-origin protection"
- "Implemented a proper receive loop checking EndOfMessage for fragmented messages and CloseStatus for disconnect"
- "Used CloseOutputAsync (not CloseAsync) when responding to client-initiated close to avoid deadlock"
- "Implemented a thread-safe connection manager using ConcurrentDictionary for tracking and broadcasting to connected clients"
- "Handled authentication via query string token since browser WebSocket API cannot send custom headers after handshake"
expect_tools: ["bash"]

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

expect_tools: ["bash"] forces the scenario to fail unless the model makes a bash tool call, but this scenario has no setup/files and only checks textual output. This constraint can make the eval flaky for no benefit; consider removing expect_tools here (or add a setup/assertion that genuinely requires bash).

Suggested change
expect_tools: ["bash"]

Copilot uses AI. Check for mistakes.
timeout: 120

- name: "Fix WebSocket CloseAsync deadlock"
prompt: |
My ASP.NET Core WebSocket endpoint sometimes hangs when clients disconnect. The server stops responding to other WebSocket connections too. Here's my close handling code:

```csharp
// When receive loop gets a close frame:
await webSocket.CloseAsync(
result.CloseStatus.Value,
result.CloseStatusDescription,
CancellationToken.None);
```

What's wrong?
assertions:
- type: "output_matches"
pattern: "(CloseOutputAsync|CloseAsync)"
rubric:
- "Identified the root cause: CloseAsync sends a close frame AND waits for the client's response, causing a deadlock when the client already initiated the close"
- "Recommended using CloseOutputAsync instead of CloseAsync when responding to a client-initiated close"
- "Explained the difference: CloseAsync = initiate close, CloseOutputAsync = respond to close"
timeout: 60

- name: "WebSocket should not be used for server-to-client streaming"
prompt: |
I need to stream live stock price updates from my server to browser clients in real-time. The clients never send messages back to the server. What's the best approach in ASP.NET Core?
expect_activation: false
assertions:
- type: "output_matches"
pattern: "(SSE|Server-Sent Events|EventSource|ServerSentEvents|text/event-stream)"
rubric:
- "Recommended Server-Sent Events (SSE) over WebSocket for this server-to-client only use case"
- "Explained why SSE is simpler: automatic reconnection, built-in browser EventSource API, no need for WebSocket connection management"
- "Did NOT suggest WebSocket as the primary recommendation for this unidirectional streaming scenario"
timeout: 60

- name: "Handle fragmented WebSocket messages correctly"
prompt: |
My ASP.NET Core WebSocket endpoint receives large JSON payloads (up to 1MB) from clients but sometimes the JSON parsing fails with truncated data. My receive code:

```csharp
var buffer = new byte[4096];
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
var json = Encoding.UTF8.GetString(buffer, 0, result.Count);
var data = JsonSerializer.Deserialize<MyData>(json);
```

What's wrong?
setup:
files:
- path: "WebSocketHandler.cs"
content: |
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;

public class WebSocketHandler
{
public async Task HandleAsync(WebSocket ws, CancellationToken ct)
{
var buffer = new byte[4096];
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
var json = Encoding.UTF8.GetString(buffer, 0, result.Count);
var data = JsonSerializer.Deserialize<Dictionary<string, object>>(json);
// Process data...
}
}
- path: "WebSocketHandler.csproj"
content: |
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
assertions:
- type: "output_matches"
pattern: "(EndOfMessage|endOfMessage)"
rubric:
- "Identified the root cause: large messages arrive in fragments and EndOfMessage is false for intermediate fragments"
- "Showed how to accumulate fragments using MemoryStream or growing buffer until EndOfMessage is true"
- "Only deserialize JSON after all fragments have been received"
- "Suggested increasing buffer size or using a MemoryStream for accumulation"
timeout: 120

- name: "WebSocket connection drops through reverse proxy"
prompt: |
My ASP.NET Core WebSocket connections work fine locally but keep dropping after about 60 seconds when deployed behind nginx. The WebSocket connects fine but idle connections get terminated. How do I fix this?
assertions:
- type: "output_matches"
pattern: "(KeepAliveInterval|KeepAlive|ping|pong)"
rubric:
- "Identified the root cause: nginx has a default idle connection timeout (typically 60s) that kills idle WebSocket connections"
- "Configured KeepAliveInterval on WebSocketOptions to send periodic ping frames that keep the connection alive"
- "Mentioned setting appropriate nginx proxy_read_timeout and proxy_send_timeout values"
timeout: 60
Loading