Add configurable media-aware managed session reaping
This commit is contained in:
+6
-2
@@ -140,8 +140,12 @@ the optional `VOICECAT_BUILD_DOTNET_ORACLE=ON` configure flag and a real-deps bu
|
||||
The server also advertises UDP on the TCP port number, supports voice subscription
|
||||
and stream signaling, and reseals encoded audio for subscribers in the same channel.
|
||||
UDP binding fixes the first endpoint for the session; reconnect after endpoint changes.
|
||||
Protected joins, administration, moderation, production configuration and full
|
||||
media-aware reaping remain before Phase 4 completion.
|
||||
Protected joins, administration, moderation and production configuration remain
|
||||
before Phase 4 completion. The server's media-aware reaper defaults to 45 seconds
|
||||
of inactivity with a 15-second sweep. Parsed control envelopes, valid encrypted
|
||||
voice and keepalives from bound endpoints refresh activity; invalid media does not.
|
||||
`VoiceServerOptions` configures timeouts and capacity; zero idle timeout disables
|
||||
reaping. The constructor overload accepts `TimeProvider` for deterministic expiry tests.
|
||||
|
||||
Enable deterministic native voice interoperability (no audio hardware required):
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using VoiceCat.Protocol;
|
||||
using System.Net.Sockets;
|
||||
using VoiceCat.Server;
|
||||
using Voicecat.V1;
|
||||
using static VoiceCat.Tests.ServerTests;
|
||||
using static VoiceCat.Tests.MediaRelayTests;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public sealed class ReaperTests
|
||||
{
|
||||
private static readonly VoiceServerOptions Options = new()
|
||||
{
|
||||
IdleTimeout = TimeSpan.FromSeconds(10), ReaperInterval = TimeSpan.FromMilliseconds(20)
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task SilentPeerIsReapedWhileTcpActivityKeepsObserverAlive()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
await using var fixture = new ServerFixture(options: Options, timeProvider: clock);
|
||||
await using var alice = await fixture.ConnectAsync();
|
||||
await alice.LoginAsync("Alice");
|
||||
await using var bob = await fixture.ConnectAsync();
|
||||
User self = await bob.LoginAsync("Bob");
|
||||
clock.Advance(9);
|
||||
alice.Send(new() { Ping = new() { Nonce = 99 } });
|
||||
await alice.ReadUntilAsync(e => e.Pong?.Nonce == 99);
|
||||
clock.Advance(2);
|
||||
Assert.Equal(self.Id, (await alice.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left)).UserEvent.LeftId);
|
||||
Assert.Equal("Receive idle timeout.", (await bob.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect.Reason);
|
||||
alice.Send(new() { Subscribe = new() });
|
||||
int additionalDepartures = 0;
|
||||
var snapshot = await alice.ReadUntilAsync(e =>
|
||||
{
|
||||
if (e.UserEvent?.Kind == UserEvent.Types.Kind.Left) additionalDepartures++;
|
||||
return e.ServerState is not null;
|
||||
});
|
||||
Assert.Equal(0, additionalDepartures);
|
||||
Assert.DoesNotContain(snapshot.ServerState.Users, user => user.Id == self.Id);
|
||||
alice.Send(new() { Ping = new() { Nonce = 100 } });
|
||||
await alice.ReadUntilAsync(e => e.Pong?.Nonce == 100);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task ValidUdpActivityKeepsTcpIdleClientAlive(bool voice)
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
await using var fixture = new ServerFixture(options: Options, timeProvider: clock);
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
uint ssrc = voice ? (await alice.AnnounceAsync(StreamKind.StreamMic)).Ssrc : 0;
|
||||
clock.Advance(9);
|
||||
if (voice)
|
||||
{
|
||||
await alice.SendAsync(alice.Seal(ssrc, [1, 2, 3]));
|
||||
await bob.ReceiveVoiceAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
byte[] keepalive = new byte[VoiceFrameHeader.Size];
|
||||
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
|
||||
await alice.SendAsync(keepalive);
|
||||
Assert.Equal(keepalive, await alice.ReceivePacketAsync());
|
||||
}
|
||||
clock.Advance(2);
|
||||
Assert.Equal(bob.Client.Authentication!.Self.Id,
|
||||
(await alice.Client.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left)).UserEvent.LeftId);
|
||||
alice.Client.Send(new() { Ping = new() { Nonce = 42 } });
|
||||
await alice.Client.ReadUntilAsync(e => e.Pong?.Nonce == 42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidVoiceCannotKeepSilentSessionAlive()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
await using var fixture = new ServerFixture(options: Options, timeProvider: clock);
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
var stream = await bob.AnnounceAsync(StreamKind.StreamMic);
|
||||
clock.Advance(9);
|
||||
byte[] forged = bob.Seal(stream.Ssrc, [1]);
|
||||
forged[^1] ^= 1;
|
||||
await bob.SendAsync(forged);
|
||||
alice.Client.Send(new() { Ping = new() { Nonce = 1 } });
|
||||
await alice.Client.ReadUntilAsync(e => e.Pong is not null);
|
||||
clock.Advance(2);
|
||||
Assert.Equal(bob.Client.Authentication!.Self.Id,
|
||||
(await alice.Client.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left)).UserEvent.LeftId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAwaitsActiveVoiceAndUnfinishedHandshake()
|
||||
{
|
||||
await using var fixture = new ServerFixture(options: Options);
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
var stream = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc, [1, 2]));
|
||||
await bob.ReceiveVoiceAsync();
|
||||
using var unfinished = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await unfinished.ConnectAsync(fixture.Server.EndPoint);
|
||||
await fixture.Server.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
await fixture.Server.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReaperCanBeDisabled()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
await using var fixture = new ServerFixture(options: Options with { IdleTimeout = TimeSpan.Zero, ReaperInterval = TimeSpan.Zero }, timeProvider: clock);
|
||||
await using var client = await fixture.ConnectAsync();
|
||||
await client.LoginAsync("Alice");
|
||||
clock.Advance(1000);
|
||||
await Task.Delay(100);
|
||||
client.Send(new() { Ping = new() { Nonce = 1 } });
|
||||
await client.ReadUntilAsync(e => e.Pong is not null);
|
||||
}
|
||||
|
||||
private sealed class ManualClock : TimeProvider
|
||||
{
|
||||
private long timestamp;
|
||||
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
|
||||
public override long GetTimestamp() => Volatile.Read(ref timestamp);
|
||||
public void Advance(int seconds) => Interlocked.Add(ref timestamp, seconds * TimeSpan.TicksPerSecond);
|
||||
}
|
||||
}
|
||||
@@ -136,10 +136,10 @@ public sealed class ServerTests
|
||||
public string Directory { get; } = Path.Combine(Path.GetTempPath(), "voicecat-server-" + Guid.NewGuid().ToString("N"));
|
||||
public VoiceServer Server { get; }
|
||||
private readonly string fingerprint;
|
||||
public ServerFixture(bool guests = true)
|
||||
public ServerFixture(bool guests = true, VoiceServerOptions? options = null, TimeProvider? timeProvider = null)
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(Directory);
|
||||
Server = new(Directory, new(IPAddress.Loopback, 0), guests);
|
||||
Server = new(Directory, new(IPAddress.Loopback, 0), options ?? new() { AllowGuests = guests }, timeProvider);
|
||||
using var credentials = ServerCredentials.LoadOrCreate(Directory, "VoiceCat Server");
|
||||
fingerprint = credentials.CertificateFingerprint;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user