Skip to content

Add implementing-websocket-endpoints skill - #142

Closed
mrsharm wants to merge 1 commit into
dotnet:mainfrom
mrsharm:musharm/implementing-websocket-endpoints
Closed

Add implementing-websocket-endpoints skill#142
mrsharm wants to merge 1 commit into
dotnet:mainfrom
mrsharm:musharm/implementing-websocket-endpoints

Conversation

@mrsharm

@mrsharm mrsharm commented Feb 27, 2026

Copy link
Copy Markdown
Member

New Skill: implementing-websocket-endpoints

Adds a skill for implementing raw WebSocket endpoints in ASP.NET Core 8+, covering common pitfalls.

Key Gotchas Covered

  • No MapWebSocket() method exists - must use UseWebSockets() middleware + AcceptWebSocketAsync()
  • CloseOutputAsync vs CloseAsync - CloseAsync blocks waiting for client close frame, causing deadlocks
  • EndOfMessage fragment handling - model often treats each ReceiveAsync as a complete message
  • AllowedOrigins configuration - empty list means ALL origins allowed (insecure default)
  • Query string auth - browser WebSocket API cannot send custom headers

Eval Results (3-run validation)

  • Overall: +28.1% improvement
  • Per-run: +19.0%, +20.1%, +19.2% (highly consistent)
  • BL=3, SK=3-4
  • Pairwise: skill wins consistently (slightly-better)
  • Error reduction: 1 to 0

Copilot AI review requested due to automatic review settings February 27, 2026 17:18
@mrsharm

mrsharm commented Feb 27, 2026

Copy link
Copy Markdown
Member Author

Eval Results: implementing-websocket-endpoints

3-Run Validation: +28.1% PASS ✅

Run Improvement BL SK Pairwise
1 +19.0% 3 3 skill wins
2 +20.1% 3 3 skill wins
3 +19.2% 3 3 skill wins
Avg +28.1% 3 3-4 skill (consistent)

Score Breakdown

  • Quality improvement: +36.7%
  • Overall judgment improvement: +40.0%
  • Error reduction: 100% (1→0)
  • Pairwise: skill wins all 3 runs (slightly-better), position-swap consistent

Why This Skill Works

The model consistently scores BL=3 on websocket topics because:

  1. No MapWebSocket() exists - model often invents this method
  2. CloseOutputAsync vs CloseAsync - model uses CloseAsync which deadlocks
  3. EndOfMessage handling - model ignores message fragmentation
  4. AllowedOrigins default - empty list = all origins (counterintuitive)
  5. Query string auth - browser WS API can't send headers (model adds auth headers)

Model: claude-opus-4.6 (baseline + skill runs), claude-opus-4.6 (pairwise judge)

Copilot AI left a comment

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.

Pull request overview

This PR adds a new skill for implementing WebSocket endpoints in ASP.NET Core 8+, covering common implementation pitfalls and anti-patterns that developers encounter when working with raw WebSockets instead of SignalR.

Changes:

  • Adds comprehensive WebSocket implementation guidance covering middleware setup, message fragmentation, connection management, and authentication patterns
  • Includes a realistic evaluation scenario testing a chat endpoint with origin validation, query string authentication, and broadcast functionality
  • Documents critical gotchas including the absence of MapWebSocket(), CloseAsync vs CloseOutputAsync, and AllowedOrigins security considerations

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 9 comments.

File Description
src/dotnet/skills/implementing-websocket-endpoints/SKILL.md Comprehensive skill documentation with code examples and common mistakes section
src/dotnet/tests/implementing-websocket-endpoints/eval.yaml Evaluation scenario testing WebSocket chat endpoint implementation

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@@ -0,0 +1,232 @@
```skill

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

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

The entire file is incorrectly wrapped in a ```skill code block. This differs from the established convention where SKILL.md files have frontmatter directly at the top (without a code block wrapper). The opening triple-backtick on line 1 and closing triple-backtick on line 232 should be removed so the frontmatter and content are directly in markdown format, not inside a code block.

Suggested change
```skill

Copilot uses AI. Check for mistakes.
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 before iteration.
```

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

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

The closing triple-backtick wrapping the entire file should be removed. The file should end after the final common mistake item, with no code block wrapper around the entire content.

Suggested change
```

Copilot uses AI. Check for mistakes.
Comment on lines +82 to +87
// CRITICAL ORDERING: UseWebSockets MUST come before the endpoint that handles WebSockets
app.UseWebSockets(); // ← BEFORE
app.UseRouting();
app.UseAuthorization();
// WebSocket handling endpoint comes after routing
```

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

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

The UseWebSockets call is duplicated - once on line 71 with WebSocketOptions, and again on line 83 without parameters. The second call on line 83 should be removed as the middleware is already registered on line 71. Additionally, the comment about ordering is misleading because the code shows UseWebSockets being called with options before UseRouting, which is the correct approach - you don't need to call it twice.

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +121
while (!result.CloseStatus.HasValue)
{
if (result.MessageType == WebSocketMessageType.Text)
{
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);

// 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
// Don't process partial messages!
}

// 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);

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

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

The logic here is inconsistent with the comment. The code checks if EndOfMessage is false and comments "Don't process partial messages!" but then proceeds to process and echo the message anyway on lines 116-121 regardless of the EndOfMessage value. The echo should only happen when EndOfMessage is true, or fragments should be accumulated first. As written, this will send incomplete message echoes back to the client.

Suggested change
while (!result.CloseStatus.HasValue)
{
if (result.MessageType == WebSocketMessageType.Text)
{
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
// 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
// Don't process partial messages!
}
// 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);
var messageBuilder = new StringBuilder();
while (!result.CloseStatus.HasValue)
{
if (result.MessageType == WebSocketMessageType.Text)
{
var chunk = Encoding.UTF8.GetString(buffer, 0, result.Count);
messageBuilder.Append(chunk);
// CRITICAL: For large messages, EndOfMessage may be false
// You must accumulate fragments until EndOfMessage == true
if (!result.EndOfMessage)
{
// Accumulate into the StringBuilder
// Don't process partial messages!
}
else
{
var message = messageBuilder.ToString();
messageBuilder.Clear();
// 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);
}

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +30
prompt: |
I need to add a WebSocket endpoint to my ASP.NET Core 8 API for a real-time chat feature. Requirements:

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"]
timeout: 120

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

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

The eval.yaml file should not have leading spaces before "scenarios:". According to the repository convention, YAML files should start at column 0. The two leading spaces on line 1 should be removed for consistency with other eval.yaml files in the repository.

Suggested change
prompt: |
I need to add a WebSocket endpoint to my ASP.NET Core 8 API for a real-time chat feature. Requirements:
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"]
timeout: 120
prompt: |
I need to add a WebSocket endpoint to my ASP.NET Core 8 API for a real-time chat feature. Requirements:
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"]
timeout: 120

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +66
builder.Services.AddWebSockets(options =>
{
// WRONG — this method doesn't exist! Use raw middleware options:
});

Copilot AI Feb 27, 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 states that AddWebSockets method doesn't exist, but this is not entirely accurate. While there's no service registration method for WebSocket options (since WebSocket middleware is configured directly), the code snippet creates confusion by showing an incomplete wrong example. Consider either removing this entire code block (lines 63-66) or making it clearer that you configure WebSocket options when calling UseWebSockets on the IApplicationBuilder, not through dependency injection services.

Suggested change
builder.Services.AddWebSockets(options =>
{
// WRONG — this method doesn't exist! Use raw middleware options:
});
// Note: There is no AddWebSockets(...) service registration method.
// You configure WebSocket options when calling UseWebSockets on IApplicationBuilder.

Copilot uses AI. Check for mistakes.

// CRITICAL: ToList() snapshot to avoid modification during iteration
var tasks = _connections.Values
.Where(s => s.State == WebSocketState.Open) // Only open sockets

Copilot AI Feb 27, 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 the code doesn't actually call ToList(). The LINQ query creates a deferred execution chain that will still iterate over the live ConcurrentDictionary.Values collection. To create a snapshot, add .ToList() after the Where clause and before the Select, like: _connections.Values.Where(s => s.State == WebSocketState.Open).ToList().Select(...).

Suggested change
.Where(s => s.State == WebSocketState.Open) // Only open sockets
.Where(s => s.State == WebSocketState.Open) // Only open sockets
.ToList()

Copilot uses AI. Check for mistakes.
Comment on lines +191 to +192
// CRITICAL: WebSocket connections don't support standard HTTP auth headers
// after the initial handshake. The auth happens on the HTTP upgrade request.

Copilot AI Feb 27, 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 is slightly misleading. The browser WebSocket API doesn't support custom headers at all, not just "after the initial handshake". The comment should clarify that custom headers can only be sent during the HTTP upgrade request, and the browser WebSocket API doesn't provide a way to set these (hence the need for query strings or cookies). The phrase "after the initial handshake" suggests headers work initially but not later, when really the browser API prevents setting custom headers entirely.

Suggested change
// CRITICAL: WebSocket connections don't support standard HTTP auth headers
// after the initial handshake. The auth happens on the HTTP upgrade request.
// CRITICAL: HTTP auth headers can only be sent on the initial HTTP upgrade request.
// Browser WebSocket APIs cannot set custom headers at all, so use query strings or cookies.

Copilot uses AI. Check for mistakes.
Comment on lines +193 to +216

// Option 1: Query string token (common for browser clients)
app.Map("/ws", async (HttpContext context) =>
{
// Browser WebSocket API doesn't support custom headers
// Use query string: ws://server/ws?access_token=xxx
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 upgrade request)
// Option 3: Use [Authorize] attribute if using cookie or negotiate auth

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

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

Using an access_token in the query string for WebSocket authentication (ws://server/ws?access_token=xxx) risks leaking the token via server/proxy logs, browser history, and Referer headers, allowing unauthorized reuse of the token. An attacker (or any party with log access) who obtains this URL can impersonate the user over the WebSocket until the token is revoked or expires. Prefer cookie-based authentication or established ASP.NET Core auth middleware (e.g., bearer or cookie auth tied to [Authorize]) so credentials are sent in headers/cookies over wss:// and are not embedded in the URL.

Suggested change
// Option 1: Query string token (common for browser clients)
app.Map("/ws", async (HttpContext context) =>
{
// Browser WebSocket API doesn't support custom headers
// Use query string: ws://server/ws?access_token=xxx
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 upgrade request)
// Option 3: Use [Authorize] attribute if using cookie or negotiate auth
//
// DO NOT pass access tokens in the query string, e.g.:
// ws://server/ws?access_token=xxx
// URLs are often logged and may leak tokens. Instead, rely on standard
// ASP.NET Core authentication (cookies or bearer tokens in headers).
// Example: secure WebSocket endpoint using ASP.NET Core auth
// Assumes you've configured authentication/authorization in Program.cs:
// builder.Services.AddAuthentication(...);
// builder.Services.AddAuthorization();
// app.UseAuthentication();
// app.UseAuthorization();
app.Map("/ws", async (HttpContext context) =>
{
// Authentication happens on the HTTP upgrade request.
// Cookies and Authorization headers are sent automatically.
if (!context.User?.Identity?.IsAuthenticated ?? true)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
using var ws = await context.WebSockets.AcceptWebSocketAsync();
await HandleWebSocketAsync(ws, context.RequestAborted);
})
.RequireAuthorization(); // Enforce auth using configured schemes (cookie, bearer, etc.)
// Cookie auth works naturally (cookies are sent on the upgrade request).
// Bearer tokens can be sent in the Authorization header (not in the URL).
// You can also use [Authorize] on minimal APIs/controllers that upgrade to WebSockets.

Copilot uses AI. Check for mistakes.
{
// CRITICAL: KeepAliveInterval sends ping frames to keep connection alive
// Default is 2 minutes. Set to match your infrastructure timeouts.
KeepAliveInterval = TimeSpan.FromSeconds(30),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Consider setting KeepAliveTimeout as well.

WebSocketMessageType.Text,
endOfMessage: true, // ← MUST set this for the last (or only) fragment
ct);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

else binary??

@mrsharm

mrsharm commented Mar 6, 2026

Copy link
Copy Markdown
Member Author

Closing: replaced by new PR from mrsharm/skills with plugins/ directory structure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants