using System.Net.WebSockets; using System.Threading.Channels; namespace HSchool.Server.Net; /// /// One connected browser. Clock frames go through a 32-slot outbox that drops the oldest under /// pressure — a stale clock is worthless. One-shot frames (the map snapshot) use a separate /// reliable channel so they cannot be crowded out by ticks. /// internal sealed class GameClient(uint playerId, WebSocket socket) { private const int OutboxCapacity = 32; private readonly Channel> _outbox = Channel.CreateBounded>(new BoundedChannelOptions(OutboxCapacity) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false, }); private readonly Channel> _reliable = Channel.CreateUnbounded>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false, }); private bool _ready; private int _openSchoolId; private int _locale; public uint PlayerId { get; } = playerId; public WebSocket Socket { get; } = socket; /// /// Set once the welcome frame is out. Clock frames are only queued for ready clients, so a /// connection never sees game state before the handshake finished. /// public bool IsReady => Volatile.Read(ref _ready); /// /// Hello locale byte. Workers read this when labelling a map snapshot; unknown values are /// treated as Russian by . /// public byte Locale { get => (byte)Volatile.Read(ref _locale); set => Volatile.Write(ref _locale, value); } /// /// School this connection is watching, or null in the menu. Written by the supervisor /// on open/close, read by the connection thread on disconnect. /// public int? OpenSchoolId { get { // School ids start at 1, so 0 stands for "this client is in the menu". var id = Volatile.Read(ref _openSchoolId); return id == 0 ? null : id; } set => Volatile.Write(ref _openSchoolId, value ?? 0); } public void MarkReady() => Volatile.Write(ref _ready, true); /// Queues a clock frame. Returns false once the connection is shutting down. public bool TrySend(ReadOnlyMemory frame) => _outbox.Writer.TryWrite(frame); /// Queues a frame that must arrive; never dropped for a newer clock. public bool TrySendReliable(ReadOnlyMemory frame) => _reliable.Writer.TryWrite(frame); /// Pumps queued frames to the socket until cancelled or both channels complete. public async Task RunSendLoopAsync(CancellationToken cancellationToken) { var reliable = _reliable.Reader; var outbox = _outbox.Reader; // The two waits live across iterations on purpose. Creating a fresh pair every idle pass // left the loser of Task.WhenAny queued on its channel forever — and because clock frames // arrive twenty times a second, the reliable channel collected one dead waiter per tick, // each holding a cancellation registration, for as long as the player stayed in a school. Task? waitReliable = null; Task? waitOutbox = null; while (Socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) { if (reliable.TryRead(out var reliableFrame)) { if (!await SendAsync(reliableFrame, cancellationToken).ConfigureAwait(false)) { return; } continue; } if (outbox.TryRead(out var clockFrame)) { if (!await SendAsync(clockFrame, cancellationToken).ConfigureAwait(false)) { return; } continue; } // A completed channel is empty for good; waiting on it again would spin. if (!reliable.Completion.IsCompleted) { waitReliable ??= reliable.WaitToReadAsync(cancellationToken).AsTask(); } if (!outbox.Completion.IsCompleted) { waitOutbox ??= outbox.WaitToReadAsync(cancellationToken).AsTask(); } if (waitReliable is null && waitOutbox is null) { return; } Task finished; try { finished = waitReliable is null ? waitOutbox! : waitOutbox is null ? waitReliable : await Task.WhenAny(waitReliable, waitOutbox).ConfigureAwait(false); await finished.ConfigureAwait(false); } catch (OperationCanceledException) { return; } // Only the wait that finished is dropped; the other one stays queued on its channel. if (ReferenceEquals(finished, waitReliable)) { waitReliable = null; } else { waitOutbox = null; } } } public void CompleteOutbox() { _reliable.Writer.TryComplete(); _outbox.Writer.TryComplete(); } private async Task SendAsync(ReadOnlyMemory frame, CancellationToken cancellationToken) { if (Socket.State != WebSocketState.Open) { return false; } await Socket.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken) .ConfigureAwait(false); return true; } }