Add configurable media-aware managed session reaping

This commit is contained in:
2026-09-15 22:58:16 +02:00
parent 05eacb3092
commit 274b85025c
14 changed files with 291 additions and 32 deletions
@@ -7,10 +7,11 @@ using VoiceCat.Protocol;
namespace VoiceCat.Server.Transport;
internal sealed class MediaPeer(byte[] token, MediaSessionCrypto crypto)
internal sealed class MediaPeer(byte[] token, MediaSessionCrypto crypto, SessionActivity? activity = null)
{
public byte[] Token { get; } = token;
public MediaSessionCrypto Crypto { get; } = crypto;
public SessionActivity Activity { get; } = activity ?? new(TimeProvider.System);
// Only the UDP loop reads or changes the endpoint and binding state.
public SocketAddress? Endpoint { get; set; }
public void Dispose() { Crypto.Dispose(); CryptographicOperations.ZeroMemory(Token); }
@@ -101,10 +102,15 @@ internal sealed class MediaRelay : IAsyncDisposable
if (source is null) continue;
if (header.Type == MediaFrameType.Keepalive)
{
if (length == VoiceFrameHeader.Size) await SendAsync(input.AsMemory(0, length), sender).ConfigureAwait(false);
if (length == VoiceFrameHeader.Size)
{
source.Peer.Activity.Touch();
await SendAsync(input.AsMemory(0, length), sender).ConfigureAwait(false);
}
continue;
}
if (!fanout.TryStart(input.AsSpan(0, length), source, current)) continue;
source.Peer.Activity.Touch();
while (fanout.TryNext(out ReadOnlyMemory<byte> packet, out SocketAddress? endpoint))
await SendAsync(packet, endpoint!).ConfigureAwait(false);
}
@@ -0,0 +1,18 @@
namespace VoiceCat.Server.Transport;
internal sealed class SessionActivity(TimeProvider clock)
{
private long lastSeen = clock.GetTimestamp();
public void Touch()
{
long now = clock.GetTimestamp();
long previous = Volatile.Read(ref lastSeen);
while (now > previous)
{
long observed = Interlocked.CompareExchange(ref lastSeen, now, previous);
if (observed == previous) return;
previous = observed;
}
}
public bool IsExpired(TimeSpan timeout) => clock.GetElapsedTime(Volatile.Read(ref lastSeen)) >= timeout;
}
@@ -27,12 +27,12 @@ internal sealed class TlsControlConnection : IAsyncDisposable
public Task Completion { get; }
public CancellationToken CancellationToken => lifetime.Token;
internal TlsControlConnection(Socket socket, TlsSession tls, CancellationToken cancellationToken)
internal TlsControlConnection(Socket socket, TlsSession tls, CancellationToken cancellationToken, TimeSpan? handshakeTimeout = null)
{
this.socket = socket;
this.tls = tls;
lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
lifetime.CancelAfter(TimeSpan.FromSeconds(15));
lifetime.CancelAfter(handshakeTimeout ?? TimeSpan.FromSeconds(15));
Completion = RunAsync();
}
@@ -94,8 +94,8 @@ internal sealed class TlsControlConnection : IAsyncDisposable
try { mediaCrypto = new(encryptor, tls.CreateMediaDecryptor()); }
catch { encryptor.Dispose(); throw; }
mediaReady.SetResult();
lifetime.CancelAfter(Timeout.InfiniteTimeSpan);
}
if (tls.IsReady) lifetime.CancelAfter(TimeSpan.FromSeconds(60));
while ((count = tls.ReadPlaintext(plaintext)) > 0) Parse(plaintext.AsSpan(0, count));
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
receive = socket.ReceiveAsync(ciphertext, SocketFlags.None, cancellationToken).AsTask();
+56 -14
View File
@@ -19,6 +19,8 @@ public sealed class VoiceServer : IAsyncDisposable
private readonly IReadOnlyList<Voicecat.V1.Channel> channels;
private readonly bool allowGuests;
private readonly string name;
private readonly VoiceServerOptions options;
private readonly TimeProvider clock;
private readonly CancellationTokenSource shutdown = new();
private readonly object gate = new();
private readonly Dictionary<ulong, Session> sessions = [];
@@ -27,6 +29,7 @@ public sealed class VoiceServer : IAsyncDisposable
private uint nextUser;
private uint nextSsrc;
private readonly Task accepting;
private readonly Task reaping;
private int disposed;
public IPEndPoint EndPoint => (IPEndPoint)listener.LocalEndPoint!;
@@ -34,9 +37,16 @@ public sealed class VoiceServer : IAsyncDisposable
public event Action<Exception>? ConnectionFailed;
public VoiceServer(string directory, IPEndPoint endpoint, bool allowGuests = true, string name = "VoiceCat Server")
: this(directory, endpoint, new VoiceServerOptions { AllowGuests = allowGuests, Name = name }) { }
public VoiceServer(string directory, IPEndPoint endpoint, VoiceServerOptions options, TimeProvider? timeProvider = null)
{
this.allowGuests = allowGuests;
this.name = name;
ArgumentNullException.ThrowIfNull(options);
options.Validate();
this.options = options;
clock = timeProvider ?? TimeProvider.System;
allowGuests = options.AllowGuests;
name = options.Name;
credentials = ServerCredentials.LoadOrCreate(directory, name);
try
{
@@ -44,7 +54,7 @@ public sealed class VoiceServer : IAsyncDisposable
channels = accounts.LoadChannels();
listener = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
listener.Bind(endpoint);
listener.Listen(64);
listener.Listen(options.MaximumConnections);
media = new((IPEndPoint)listener.LocalEndPoint!);
media.Failed += exception => ConnectionFailed?.Invoke(exception);
}
@@ -57,6 +67,7 @@ public sealed class VoiceServer : IAsyncDisposable
throw;
}
accepting = AcceptAsync();
reaping = ReapAsync();
}
private async Task AcceptAsync()
@@ -68,11 +79,11 @@ public sealed class VoiceServer : IAsyncDisposable
Socket socket = await listener.AcceptAsync(shutdown.Token).ConfigureAwait(false);
lock (gate)
{
if (sessions.Count >= 64) { socket.Dispose(); continue; }
if (sessions.Count >= options.MaximumConnections) { socket.Dispose(); continue; }
socket.NoDelay = true;
string address = ((IPEndPoint)socket.RemoteEndPoint!).Address.ToString();
var connection = new TlsControlConnection(socket, credentials.CreateTlsSession(), shutdown.Token);
var session = new Session(++nextSession, connection, address);
var connection = new TlsControlConnection(socket, credentials.CreateTlsSession(), shutdown.Token, options.HandshakeTimeout);
var session = new Session(++nextSession, connection, address, new(clock));
sessions.Add(session.Id, session);
connections.RemoveAll(task => task.IsCompleted);
connections.Add(HandleAsync(session));
@@ -88,6 +99,7 @@ public sealed class VoiceServer : IAsyncDisposable
{
await foreach (Envelope envelope in session.Connection.ReadAsync(shutdown.Token).ConfigureAwait(false))
{
session.Activity.Touch();
if (envelope.Ping is not null)
{
session.Connection.TrySend(new() { RequestId = envelope.RequestId, Pong = new() { Nonce = envelope.Ping.Nonce } });
@@ -101,7 +113,7 @@ public sealed class VoiceServer : IAsyncDisposable
Reject(session, "Unsupported protocol version or banned address.");
break;
}
session.Media = new(RandomNumberGenerator.GetBytes(16), await session.Connection.TakeMediaCryptoAsync(shutdown.Token).ConfigureAwait(false));
session.Media = new(RandomNumberGenerator.GetBytes(16), await session.Connection.TakeMediaCryptoAsync(shutdown.Token).ConfigureAwait(false), session.Activity);
var hello = new ServerHello { ProtoVersion = 2, ServerName = name, ServerVersion = "0.1.0-dotnet", UdpPort = checked((uint)media.EndPoint.Port), ServerIdentityFingerprint = ByteString.CopyFrom(SHA256.HashData(credentials.Identity.PublicKey)) };
if (allowGuests) hello.AuthMethods.Add("guest");
hello.AuthMethods.Add("password");
@@ -159,6 +171,28 @@ public sealed class VoiceServer : IAsyncDisposable
session.Connection.CompleteWrites();
}
private async Task ReapAsync()
{
if (options.IdleTimeout == TimeSpan.Zero) return;
using var timer = new PeriodicTimer(options.ReaperInterval, clock);
try
{
while (await timer.WaitForNextTickAsync(shutdown.Token).ConfigureAwait(false))
{
lock (gate)
{
foreach (Session session in sessions.Values)
{
if (session.Closing || !session.Activity.IsExpired(options.IdleTimeout)) continue;
session.Closing = true;
Reject(session, "Receive idle timeout.");
}
}
}
}
catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { }
}
private async Task AuthenticateAsync(Session session, ulong requestId, AuthRequest request)
{
User? user = null;
@@ -321,23 +355,31 @@ public sealed class VoiceServer : IAsyncDisposable
listener.Dispose();
try
{
await accepting.ConfigureAwait(false);
Task[] pending;
lock (gate) pending = connections.ToArray();
await Task.WhenAll(pending).ConfigureAwait(false);
await Task.WhenAll(accepting, reaping).ConfigureAwait(false);
}
finally
{
try { await media.DisposeAsync().ConfigureAwait(false); }
finally { accounts.Dispose(); credentials.Dispose(); shutdown.Dispose(); }
try
{
Task[] pending;
lock (gate) pending = connections.ToArray();
await Task.WhenAll(pending).ConfigureAwait(false);
}
finally
{
try { await media.DisposeAsync().ConfigureAwait(false); }
finally { accounts.Dispose(); credentials.Dispose(); shutdown.Dispose(); }
}
}
}
private sealed class Session(ulong id, TlsControlConnection connection, string address)
private sealed class Session(ulong id, TlsControlConnection connection, string address, SessionActivity activity)
{
public ulong Id { get; } = id;
public TlsControlConnection Connection { get; } = connection;
public string Address { get; } = address;
public SessionActivity Activity { get; } = activity;
public bool Closing { get; set; }
public bool HelloReceived { get; set; }
public User? User { get; set; }
public MediaPeer? Media { get; set; }
@@ -0,0 +1,21 @@
namespace VoiceCat.Server;
public sealed record VoiceServerOptions
{
public string Name { get; init; } = "VoiceCat Server";
public bool AllowGuests { get; init; } = true;
public int MaximumConnections { get; init; } = 64;
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(15);
public TimeSpan IdleTimeout { get; init; } = TimeSpan.FromSeconds(45);
public TimeSpan ReaperInterval { get; init; } = TimeSpan.FromSeconds(15);
internal void Validate()
{
ArgumentException.ThrowIfNullOrWhiteSpace(Name);
ArgumentOutOfRangeException.ThrowIfLessThan(MaximumConnections, 1);
if (HandshakeTimeout <= TimeSpan.Zero || HandshakeTimeout.TotalMilliseconds > uint.MaxValue - 1) throw new ArgumentOutOfRangeException(nameof(HandshakeTimeout));
if (IdleTimeout < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(IdleTimeout));
if (ReaperInterval < TimeSpan.Zero || ReaperInterval.TotalMilliseconds > uint.MaxValue - 1 || IdleTimeout > TimeSpan.Zero && ReaperInterval == TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(ReaperInterval));
}
}