279 lines
11 KiB
C#
279 lines
11 KiB
C#
using System.Buffers;
|
|
using System.Net.WebSockets;
|
|
using HSchool.Protocol;
|
|
using HSchool.Server.Game;
|
|
using HSchool.Simulation;
|
|
|
|
namespace HSchool.Server.Net;
|
|
|
|
/// <summary>
|
|
/// Drives one WebSocket connection: version handshake, then the receive loop that turns frames
|
|
/// into commands. Everything it reads from the wire is untrusted, so frames are validated before
|
|
/// anything reaches a school worker.
|
|
/// </summary>
|
|
internal sealed class GameSocketHandler(
|
|
ClientRegistry clients,
|
|
GameCommandQueue commands,
|
|
GameLoopService loop,
|
|
GameMetrics metrics,
|
|
ILogger<GameSocketHandler> logger)
|
|
{
|
|
private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(5);
|
|
|
|
public async Task HandleAsync(WebSocket socket, string userName, CancellationToken cancellationToken)
|
|
{
|
|
var client = clients.Add(socket);
|
|
var buffer = ArrayPool<byte>.Shared.Rent(ProtocolConstants.MaxMessageSize);
|
|
using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
|
|
Task? sendLoop = null;
|
|
metrics.ClientConnected();
|
|
|
|
try
|
|
{
|
|
if (!clients.TryClaimUserName(client, userName))
|
|
{
|
|
await CloseAsync(
|
|
socket,
|
|
WebSocketCloseStatus.PolicyViolation,
|
|
"Name is already online.",
|
|
cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(connectionCts.Token);
|
|
handshakeCts.CancelAfter(HandshakeTimeout);
|
|
|
|
var helloLength = await ReceiveFrameAsync(socket, buffer, handshakeCts.Token).ConfigureAwait(false);
|
|
if (helloLength <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var hello = ProtocolCodec.ReadHello(buffer.AsSpan(0, helloLength));
|
|
if (hello.ProtocolVersion != ProtocolConstants.Version)
|
|
{
|
|
logger.LogWarning(
|
|
"Rejecting client {PlayerId}: protocol v{ClientVersion}, server speaks v{ServerVersion}.",
|
|
client.PlayerId,
|
|
hello.ProtocolVersion,
|
|
ProtocolConstants.Version);
|
|
|
|
await CloseAsync(
|
|
socket,
|
|
WebSocketCloseStatus.ProtocolError,
|
|
$"Protocol v{ProtocolConstants.Version} required.",
|
|
cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
client.Locale = hello.Locale;
|
|
|
|
await SendWelcomeAsync(socket, connectionCts.Token).ConfigureAwait(false);
|
|
client.MarkReady();
|
|
|
|
// From here on every outbound frame goes through the outbox, so there is
|
|
// exactly one writer on the socket.
|
|
sendLoop = client.RunSendLoopAsync(connectionCts.Token);
|
|
|
|
await ReceiveLoopAsync(client, buffer, connectionCts.Token).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Client went away or the host is shutting down.
|
|
}
|
|
catch (ProtocolException ex)
|
|
{
|
|
logger.LogWarning(ex, "Malformed frame from client {PlayerId}.", client.PlayerId);
|
|
await CloseAsync(socket, WebSocketCloseStatus.InvalidPayloadData, "Malformed frame.", CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (WebSocketException ex)
|
|
{
|
|
logger.LogDebug(ex, "Connection {PlayerId} dropped.", client.PlayerId);
|
|
}
|
|
finally
|
|
{
|
|
ArrayPool<byte>.Shared.Return(buffer);
|
|
clients.Remove(client.PlayerId);
|
|
client.CompleteOutbox();
|
|
metrics.ClientDisconnected();
|
|
|
|
// Read after the removal above: an open that lands later finds no client and is
|
|
// dropped, so this is the last chance to see the school this connection was watching.
|
|
if (client.OpenSchoolId is { } watchedSchoolId)
|
|
{
|
|
commands.Enqueue(new GameCommand.CloseSchool(client.PlayerId, watchedSchoolId));
|
|
}
|
|
|
|
// Cancel first, then wait. The other order hung here whenever the send loop sat inside
|
|
// SendAsync on a half-open connection: nothing was left to cancel it.
|
|
await connectionCts.CancelAsync().ConfigureAwait(false);
|
|
|
|
if (sendLoop is not null)
|
|
{
|
|
try
|
|
{
|
|
await sendLoop.ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException)
|
|
{
|
|
// Expected while tearing the connection down.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task ReceiveLoopAsync(GameClient client, byte[] buffer, CancellationToken cancellationToken)
|
|
{
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
var length = await ReceiveFrameAsync(client.Socket, buffer, cancellationToken).ConfigureAwait(false);
|
|
if (length <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var frame = buffer.AsSpan(0, length);
|
|
switch (ProtocolCodec.PeekMessageType(frame))
|
|
{
|
|
case MessageType.ClientPing:
|
|
SendPong(client, ProtocolCodec.ReadPing(frame).ClientTimeMs);
|
|
break;
|
|
|
|
case MessageType.ClientOpenSchool:
|
|
var open = ProtocolCodec.ReadOpenSchool(frame);
|
|
commands.Enqueue(new GameCommand.OpenSchool(client.PlayerId, open.SchoolId));
|
|
break;
|
|
|
|
case MessageType.ClientCloseSchool:
|
|
if (client.OpenSchoolId is { } openSchoolId)
|
|
{
|
|
commands.Enqueue(new GameCommand.CloseSchool(client.PlayerId, openSchoolId));
|
|
}
|
|
|
|
break;
|
|
|
|
case MessageType.ClientSetRunning:
|
|
var setRunning = ProtocolCodec.ReadSetRunning(frame);
|
|
if (TryNormalizedUser(client, out var runningUser))
|
|
{
|
|
commands.Enqueue(new GameCommand.SetRunning(client.PlayerId, setRunning.Running, runningUser));
|
|
}
|
|
|
|
break;
|
|
|
|
case MessageType.ClientSetSpeed:
|
|
var setSpeed = ProtocolCodec.ReadSetSpeed(frame);
|
|
if (TryNormalizedUser(client, out var speedUser))
|
|
{
|
|
commands.Enqueue(new GameCommand.SetSpeed(client.PlayerId, setSpeed.SpeedIndex, speedUser));
|
|
}
|
|
|
|
break;
|
|
|
|
case MessageType.ClientSkipEmpty:
|
|
if (TryNormalizedUser(client, out var skipUser))
|
|
{
|
|
commands.Enqueue(new GameCommand.SkipEmpty(client.PlayerId, skipUser));
|
|
}
|
|
|
|
break;
|
|
|
|
case MessageType.ClientDismissNotice:
|
|
var dismiss = ProtocolCodec.ReadDismissNotice(frame);
|
|
commands.Enqueue(new GameCommand.DismissNotice(client.PlayerId, dismiss.Id));
|
|
break;
|
|
|
|
default:
|
|
logger.LogDebug(
|
|
"Ignoring unexpected frame 0x{MessageType:X2} from client {PlayerId}.",
|
|
frame[0],
|
|
client.PlayerId);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Reads one whole message. Returns 0 on close, -1 on an oversized or non-binary frame.</summary>
|
|
private async Task<int> ReceiveFrameAsync(WebSocket socket, byte[] buffer, CancellationToken cancellationToken)
|
|
{
|
|
var offset = 0;
|
|
|
|
while (true)
|
|
{
|
|
var result = await socket
|
|
.ReceiveAsync(new ArraySegment<byte>(buffer, offset, buffer.Length - offset), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (result.MessageType == WebSocketMessageType.Close)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
if (result.MessageType != WebSocketMessageType.Binary)
|
|
{
|
|
logger.LogDebug("Dropping non-binary frame.");
|
|
return -1;
|
|
}
|
|
|
|
offset += result.Count;
|
|
|
|
if (result.EndOfMessage)
|
|
{
|
|
return offset;
|
|
}
|
|
|
|
if (offset >= buffer.Length)
|
|
{
|
|
logger.LogWarning("Frame exceeds {Limit} bytes; closing.", ProtocolConstants.MaxMessageSize);
|
|
await CloseAsync(socket, WebSocketCloseStatus.MessageTooBig, "Frame too large.", cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task SendWelcomeAsync(WebSocket socket, CancellationToken cancellationToken)
|
|
{
|
|
var options = loop.Options;
|
|
var frame = new byte[ProtocolCodec.MaxFrameSize];
|
|
var length = ProtocolCodec.WriteWelcome(
|
|
frame,
|
|
new ServerWelcomeMessage(ProtocolConstants.Version, (byte)options.TickRate, (byte)options.MaxSchools));
|
|
|
|
await socket
|
|
.SendAsync(frame.AsMemory(0, length), WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
private void SendPong(GameClient client, long clientTimeMs)
|
|
{
|
|
var frame = new byte[ProtocolCodec.MaxFrameSize];
|
|
var length = ProtocolCodec.WritePong(frame, new ServerPongMessage(clientTimeMs, loop.CurrentTick));
|
|
client.TrySend(frame.AsMemory(0, length));
|
|
}
|
|
|
|
private static bool TryNormalizedUser(GameClient client, out string normalized) =>
|
|
SchoolNames.TryNormalize(client.UserName, out normalized);
|
|
|
|
private static async Task CloseAsync(
|
|
WebSocket socket,
|
|
WebSocketCloseStatus status,
|
|
string description,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
|
{
|
|
try
|
|
{
|
|
await socket.CloseAsync(status, description, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (WebSocketException)
|
|
{
|
|
// The peer may already be gone; nothing left to do.
|
|
}
|
|
}
|
|
}
|
|
}
|