diff --git a/PROGRESS.md b/PROGRESS.md index 7f37c7c..aee8cfe 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -29,6 +29,15 @@ The iOS user list now opens a remote-user detail view with independent tuning fo audio stream. Private messages are grouped into per-user conversations with direct access to the same user and audio controls. +The media path now survives changing networks. A client whose source address changes proves +possession of its media key from the new address with an authenticated `Rebind` frame and the +relay moves its endpoint, instead of the session dying silently in both directions; the client +rebuilds its UDP socket rather than retrying on one pinned to a vanished interface. The control +connection is judged live by server traffic rather than assumed live, so a blackholed TCP path +is detected in 30 s instead of waiting minutes for the OS. The receive jitter buffer keeps a +one-frame depth floor, measures late and reordered arrivals, and can deepen mid-call, and a +stalled consumer now costs bounded audio rather than the live talkspurt. + SQLite schema v4 persists DRED and the channel packet-loss mode. Manual loss remains the default; automatic Fast/Balanced/Stable modes measure each sender's authenticated UDP uplink at the server, cap the applied Opus hint at 30%, and feed it back over TLS. @@ -47,7 +56,8 @@ cap the applied Opus hint at 30%, and feed it back over TLS. counters (the render callback now paces the mix and the 20 ms capture handoff, and a watchdog rebuilds a graph that stops calling back). Take a Siri or phone-call interruption while backgrounded and confirm audio resumes without foregrounding. Complete a - 30-minute iOS call and Wi-Fi/cellular switching with voice restoration, plus extended + 30-minute iOS call and Wi-Fi/cellular switching with voice restoration (the switch is covered + by simulation in `NetworkImpairmentTests`; hardware confirms the real route change), plus extended mono/stereo/voice-chat switching while joined. Verify Windows desktop/per-app stereo sharing. - Complete Developer ID signing/notarization. The iOS host and ReplayKit extension have been distribution-signed and packaged locally; upload the IPA for Apple's server-side validation. diff --git a/docs/architecture.md b/docs/architecture.md index 331f118..8304135 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,6 +68,12 @@ need direct native entry points. These are platform adapters, not a second core. - `proto/voicecat.proto` is the control-plane schema. - Media uses the fixed header and AEAD construction described in `protocol.md` and `security.md`. +- Media frame types are a versioned contract: `Voice`, `Keepalive`, `UdpBinding`, and `Rebind`. + `UdpBinding` establishes a peer's endpoint once, in the clear. `Rebind` moves an established + endpoint after the client's source address changes, as on a Wi-Fi/cellular handover; it + carries the binding token in the clear for peer lookup only, and authorization comes from the + AEAD tag over header and token plus the peer's replay window, so a captured rebind cannot be + replayed to redirect someone else's downlink. - SQLite is the server's persistent store; schema changes require explicit migrations. - Client profiles and TOFU pins are local platform data. - The ReplayKit ring layout is separately versioned and frozen. diff --git a/src/VoiceCat.Audio/ReceiveStream.cs b/src/VoiceCat.Audio/ReceiveStream.cs index 17b79ce..3b2fbf9 100644 --- a/src/VoiceCat.Audio/ReceiveStream.cs +++ b/src/VoiceCat.Audio/ReceiveStream.cs @@ -25,7 +25,11 @@ internal sealed class ReceiveStream : IDisposable private readonly byte[][] jitter; private readonly uint[] timestamps; private readonly int[] sizes; - private int count, available, offset, missing, waiting; + private int count, available, offset, missing, waiting, stretchCooldown; + // One held frame per half second. Depth still follows a degrading link within a few seconds, + // but a target pinned at its cap can no longer trade a steady stream of concealment against + // depth it will never reach, nor oscillate against CatchUp's trim. + private const int StretchCooldownFrames = 25; private uint expected; private bool started, hasTimestamp; private bool hasMarker; @@ -40,6 +44,8 @@ internal sealed class ReceiveStream : IDisposable internal int ConcealedFrames { get; private set; } internal int DredFrames { get; private set; } internal int FecFrames { get; private set; } + internal int Overruns { get; private set; } + internal int Stretches { get; private set; } internal ReceiveStream(uint userId, StreamInfo info, TimeProvider? clock = null) { @@ -60,14 +66,22 @@ internal sealed class ReceiveStream : IDisposable internal bool Enqueue(VoiceFrameHeader header, ReadOnlySpan payload) { int index = written; - if (payload.Length is < 1 or > 1275 || unchecked(index - Volatile.Read(ref read)) >= 64) return false; + if (payload.Length is < 1 or > 1275) return false; + // A stalled consumer (an interrupted or rebuilding iOS graph) stops draining this + // handoff. Refusing new packets would hold a ring of audio that is already too old to + // play and discard the live talkspurt instead, so the newest always wins and the + // consumer — which alone owns `read` — notices the overrun and skips forward. int slot = index & 63; payload.CopyTo(packets[slot]); headers[slot] = header; lengths[slot] = payload.Length; arrivals[slot] = clock.GetTimestamp(); Volatile.Write(ref written, unchecked(index + 1)); return true; } private void Drain() { - while (read != Volatile.Read(ref written)) + int end = Volatile.Read(ref written); + // Leave a margin below the producer rather than resuming exactly 64 back, so a write + // during this drain cannot lap the slot being copied. + if (unchecked(end - read) > 64) { read = unchecked(end - 32); Overruns++; } + while (read != end) { int source = read & 63; uint timestamp = headers[source].Timestamp; if (!hasTimestamp) { hasTimestamp = true; expected = timestamp; } @@ -78,8 +92,16 @@ internal sealed class ReceiveStream : IDisposable DropBefore(timestamp); available = offset = missing = waiting = 0; expected = timestamp; started = false; delta = 0; lastArrival = 0; jitterSamples = 0; } + // Observed before the acceptance test below: arrival statistics describe the network, + // not what this buffer could use. Measuring only accepted packets let a buffer that + // was too shallow reject the very late arrivals that should have deepened it. + ObserveArrival(timestamp, arrivals[source]); bool duplicate = false; for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && timestamps[i] == timestamp) duplicate = true; + // Frames behind `expected` are unplayable: it is the playout clock and never moves + // backward, so a late arrival would sit here forever and, as the oldest entry, would + // also mask the future packet that DRED and FEC recover from. Depth, not late + // tolerance, is what absorbs reordering here. if ((!started || delta >= 0) && delta % frameSamples == 0 && !duplicate) { if (count >= maximumDepth) @@ -89,7 +111,6 @@ internal sealed class ReceiveStream : IDisposable int target = Array.IndexOf(sizes, 0); timestamps[target] = timestamp; sizes[target] = lengths[source]; packets[source].AsSpan(0, lengths[source]).CopyTo(jitter[target]); count++; - ObserveArrival(timestamp, arrivals[source]); } Volatile.Write(ref read, unchecked(read + 1)); } @@ -100,8 +121,10 @@ internal sealed class ReceiveStream : IDisposable if (lastArrival != 0) { int timestampDelta = unchecked((int)(timestamp - lastArrivalTimestamp)); - if (timestampDelta <= 0) return; - if (timestampDelta > 0 && timestampDelta <= frameSamples * 10) + // A reordered packet arrives with a negative timestamp delta, and that is precisely + // the arrival the target depth has to absorb. Ignoring it left reordering invisible + // to the estimator. Gaps far beyond a frame are talkspurt silence, not jitter. + if (Math.Abs(timestampDelta) <= frameSamples * 10) { double arrivalDelta = clock.GetElapsedTime(lastArrival, arrival).TotalSeconds * 48_000; double deviation = Math.Abs(arrivalDelta - timestampDelta); @@ -111,9 +134,12 @@ internal sealed class ReceiveStream : IDisposable lastArrival = arrival; lastArrivalTimestamp = timestamp; } + // The playout target never drops below a single frame. A zero target starts playout on one + // packet with no depth at all, so ordinary reordering becomes concealment even when nothing + // was actually lost; recovery modes need a further frame of lookahead on top of that floor. private int TargetSamples() { - int recovery = Info.Audio.Dred || Info.Audio.Fec ? frameSamples : 0; + int recovery = Info.Audio.Dred || Info.Audio.Fec ? frameSamples * 2 : frameSamples; int variation = checked((int)Math.Ceiling(4 * jitterSamples / frameSamples)) * frameSamples; return Math.Min(5760, recovery + variation); } @@ -155,6 +181,23 @@ internal sealed class ReceiveStream : IDisposable int found = -1; for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && timestamps[i] == expected) { found = i; break; } bool decoded = false; + // The playout clock advances one frame per call, so a target that grows mid-call has no + // way to deepen the buffer again. Hold the clock for one frame — concealing instead of + // consuming — so the standing depth can follow a link that has become jittery. Only when + // the expected frame is actually present, otherwise a loss gap would stall playout, and + // CatchUp's trim threshold sits two frames above this so the two cannot oscillate. + if (stretchCooldown > 0) stretchCooldown--; + if (started && found >= 0 && count > 0 && stretchCooldown == 0) + { + int ahead = Newest(); + if (unchecked((int)(timestamps[ahead] - expected)) + frameSamples < TargetSamples()) + { + stretchCooldown = StretchCooldownFrames; + if (!decoder.TryDecode([], pcm, frameSamples, out _)) pcm.AsSpan(0, frameSamples * channels).Clear(); + ConcealedFrames++; Stretches++; + return; + } + } if (found >= 0) { decoded = decoder.TryDecode(jitter[found].AsSpan(0, sizes[found]), pcm, frameSamples, out int result) && result == frameSamples; diff --git a/src/VoiceCat.Core/ClientMediaTransport.cs b/src/VoiceCat.Core/ClientMediaTransport.cs index 462b235..198a6c8 100644 --- a/src/VoiceCat.Core/ClientMediaTransport.cs +++ b/src/VoiceCat.Core/ClientMediaTransport.cs @@ -10,7 +10,9 @@ public delegate void EncodedVoiceHandler(VoiceFrameHeader header, ReadOnlySpan bound.Task; @@ -28,10 +37,13 @@ internal sealed class ClientMediaTransport : IAsyncDisposable { if (token.Length != 16) throw new IOException("Invalid UDP binding token."); this.crypto = crypto; + this.endpoint = endpoint; + token.CopyTo(this.token.AsSpan()); stop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); socket = new(endpoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp); try { socket.Connect(endpoint); } catch { socket.Dispose(); stop.Dispose(); throw; } + lastInbound = Environment.TickCount64; new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding); token.CopyTo(binding.AsSpan(VoiceFrameHeader.Size)); new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive); @@ -52,6 +64,7 @@ internal sealed class ClientMediaTransport : IAsyncDisposable private void Send() { byte[] plain = new byte[1275], packet = new byte[1275 + VoiceFrameHeader.Size + MediaEncryptor.TagSize]; + byte[] rebind = new byte[MediaEncryptor.RebindSize]; long nextKeepalive = 0; try { @@ -60,23 +73,39 @@ internal sealed class ClientMediaTransport : IAsyncDisposable bool drained = false; try { + // A bound path that stops echoing keepalives has been lost under us. The + // socket is connected, so it is still pinned to a source address that may no + // longer exist; rebuild it and prove possession of the media key from the + // new one so the relay moves this peer's endpoint. + if (bound.Task.IsCompleted && Environment.TickCount64 - Volatile.Read(ref lastInbound) > RecoveryIdleMilliseconds) + { + Rebuild(); + int size = crypto.Encryptor.EncryptRebind(token, rebind); + Volatile.Read(ref socket).Send(rebind.AsSpan(0, size), SocketFlags.None); + Migrations++; + Volatile.Write(ref lastInbound, Environment.TickCount64); + nextKeepalive = 0; + } if (Environment.TickCount64 >= nextKeepalive) { - if (!bound.Task.IsCompleted) socket.Send(binding, SocketFlags.None); - socket.Send(keepalive, SocketFlags.None); - nextKeepalive = Environment.TickCount64 + (bound.Task.IsCompleted ? 5000 : 250); + Socket current = Volatile.Read(ref socket); + if (!bound.Task.IsCompleted) current.Send(binding, SocketFlags.None); + current.Send(keepalive, SocketFlags.None); + nextKeepalive = Environment.TickCount64 + (bound.Task.IsCompleted ? KeepaliveMilliseconds : 250); } while (packets.TryRead(plain, out VoiceFrameHeader header, out int length)) { drained = true; int size = crypto.Encryptor.Encrypt(header, plain.AsSpan(0, length), packet); - socket.Send(packet.AsSpan(0, size), SocketFlags.None); + Volatile.Read(ref socket).Send(packet.AsSpan(0, size), SocketFlags.None); } } catch (SocketException exception) when (IsTransientNetworkError(exception)) { - // iOS can briefly lose its UDP route while Wi-Fi and cellular switch. - // Keep the sender and socket alive so the next route can carry media. + // iOS loses its UDP route while Wi-Fi and cellular switch. A connected socket + // stays bound to the vanished source address, so retrying on it never + // recovers; replace it and let the idle check above re-offer the binding. + Rebuild(); nextKeepalive = 0; Thread.Sleep(100); } @@ -98,6 +127,18 @@ internal sealed class ClientMediaTransport : IAsyncDisposable finally { stop.Cancel(); } } + // Replaces the UDP socket so the next send leaves over whichever interface is now current. + // Only the sender thread rebuilds; the receive loop picks the new socket up on its next read. + private void Rebuild() + { + if (stop.IsCancellationRequested) return; + Socket replacement = new(endpoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp); + try { replacement.Connect(endpoint); } + catch { replacement.Dispose(); return; } + Socket previous = Interlocked.Exchange(ref socket, replacement); + previous.Dispose(); + } + private async Task ReceiveAsync() { byte[] packet = new byte[65535], plain = new byte[65535]; @@ -106,10 +147,14 @@ internal sealed class ClientMediaTransport : IAsyncDisposable while (true) { int length; - try { length = await socket.ReceiveAsync(packet, SocketFlags.None, stop.Token).ConfigureAwait(false); } + Socket current = Volatile.Read(ref socket); + try { length = await current.ReceiveAsync(packet, SocketFlags.None, stop.Token).ConfigureAwait(false); } catch (SocketException exception) when (exception.SocketErrorCode is SocketError.ConnectionReset or SocketError.MessageSize) { continue; } catch (SocketException exception) when (IsTransientNetworkError(exception)) { await Task.Delay(100, stop.Token).ConfigureAwait(false); continue; } + // The sender replaced the socket under us during a migration; read the new one. + catch (ObjectDisposedException) when (!stop.IsCancellationRequested && !ReferenceEquals(current, Volatile.Read(ref socket))) { continue; } + Volatile.Write(ref lastInbound, Environment.TickCount64); if (!VoiceFrameHeader.TryRead(packet.AsSpan(0, length), out var candidate)) continue; if (candidate.Type == MediaFrameType.Keepalive && length == VoiceFrameHeader.Size) { bound.TrySetResult(); continue; } if (candidate.Type != MediaFrameType.Voice || candidate.Codec != 0 || @@ -128,9 +173,14 @@ internal sealed class ClientMediaTransport : IAsyncDisposable public async ValueTask DisposeAsync() { - stop.Cancel(); socket.Dispose(); sendReady.Set(); + stop.Cancel(); Volatile.Read(ref socket).Dispose(); sendReady.Set(); try { sending.Join(); await receiving.ConfigureAwait(false); } - finally { System.Security.Cryptography.CryptographicOperations.ZeroMemory(binding); stop.Dispose(); sendReady.Dispose(); } + finally + { + System.Security.Cryptography.CryptographicOperations.ZeroMemory(binding); + System.Security.Cryptography.CryptographicOperations.ZeroMemory(token); + stop.Dispose(); sendReady.Dispose(); + } } // A bounded, allocation-free packet handoff. A contending producer drops instead of diff --git a/src/VoiceCat.Core/VoiceCatClient.cs b/src/VoiceCat.Core/VoiceCatClient.cs index b756e6e..6858fe1 100644 --- a/src/VoiceCat.Core/VoiceCatClient.cs +++ b/src/VoiceCat.Core/VoiceCatClient.cs @@ -42,6 +42,16 @@ public sealed partial class VoiceCatClient : IAsyncDisposable private uint adaptiveLossChannel; private int adaptiveLossPercent = -1; private ClientConnectionState state; + private long lastControlInbound; + // A control connection that stops answering is dead even though the socket still looks open. + // A phone that changes interface leaves TCP blackholed rather than reset, and the OS will not + // report it for minutes, so liveness is judged here instead. + private TimeSpan controlKeepaliveInterval = TimeSpan.FromSeconds(10); + private TimeSpan controlSilenceTimeout = TimeSpan.FromSeconds(30); + + // Instance scoped so tests can shorten the window without disturbing parallel tests. + internal void SetControlLiveness(TimeSpan keepalive, TimeSpan silenceTimeout) + { controlKeepaliveInterval = keepalive; controlSilenceTimeout = silenceTimeout; } public event Action? ConnectionStateChanged; public ClientConnectionState State { get { lock (stateGate) return state; } } @@ -178,8 +188,20 @@ public sealed partial class VoiceCatClient : IAsyncDisposable { try { - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10)); - while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) Send(new() { Ping = new() { Nonce = checked((ulong)Environment.TickCount64) } }); + Volatile.Write(ref lastControlInbound, Environment.TickCount64); + using var timer = new PeriodicTimer(controlKeepaliveInterval); + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + // Every server reply counts as liveness, so a busy session never trips this; an + // unanswered ping is what exposes a path that has stopped carrying anything. + if (Environment.TickCount64 - Volatile.Read(ref lastControlInbound) > controlSilenceTimeout.TotalMilliseconds) + { + ConnectionFailure ??= new IOException("Server stopped responding on the control connection."); + connectionLifetime?.Cancel(); + break; + } + Send(new() { Ping = new() { Nonce = checked((ulong)Environment.TickCount64) } }); + } } catch (Exception exception) when (exception is OperationCanceledException or IOException or InvalidOperationException) { } } @@ -212,13 +234,16 @@ public sealed partial class VoiceCatClient : IAsyncDisposable { await foreach (Envelope message in connection.ReadAsync(cancellationToken).ConfigureAwait(false)) { + Volatile.Write(ref lastControlInbound, Environment.TickCount64); Apply(message); if (message.RequestId != 0 && pending.TryRemove(message.RequestId, out var completion)) completion.TrySetResult(message.Clone()); if (!events.Writer.TryWrite(message.Clone())) throw new IOException("Client event queue exhausted; consume events regularly."); if (message.Disconnect is not null) { connection.CompleteWrites(); break; } } } - catch (Exception exception) { failure = exception; ConnectionFailure = exception; } + // A liveness failure has already recorded the real cause and cancelled this read, so do + // not replace it with the cancellation it produced. + catch (Exception exception) { failure = exception; ConnectionFailure ??= exception; } finally { connectionLifetime?.Cancel(); diff --git a/src/VoiceCat.Crypto/MediaDecryptor.cs b/src/VoiceCat.Crypto/MediaDecryptor.cs index b92af61..4542fc3 100644 --- a/src/VoiceCat.Crypto/MediaDecryptor.cs +++ b/src/VoiceCat.Crypto/MediaDecryptor.cs @@ -25,20 +25,42 @@ public sealed class MediaDecryptor : IDisposable if (packet.Overlaps(plaintext)) throw new ArgumentException("Input and output must not overlap.", nameof(plaintext)); VoiceFrameHeader.TryRead(packet, out var candidate); ulong sequence = candidate.Sequence; - if (initialized && sequence <= highestSequence) - { - ulong offset = highestSequence - sequence; - if (offset >= 64 || (replayWindow & (1UL << (int)offset)) != 0) return false; - } + if (IsReplay(sequence)) return false; if (!cipher.TryDecrypt(sequence, packet[VoiceFrameHeader.Size..], packet[..VoiceFrameHeader.Size], plaintext[..length])) return false; + Accept(sequence); + header = candidate; + bytesWritten = length; + return true; + } - // Only authenticated counters may move the replay window. - if (!initialized) - { - highestSequence = sequence; - replayWindow = 1; - initialized = true; - } + // Verifies an endpoint-migration frame: header, plaintext binding token, and tag. The token + // is authenticated as additional data, so only the media key holder can move an endpoint, + // and the shared replay window makes a captured frame useless to an on-path observer. + public bool TryVerifyRebind(ReadOnlySpan packet, out ReadOnlySpan token) + { + ObjectDisposedException.ThrowIf(disposed, this); + token = default; + if (packet.Length != MediaEncryptor.RebindSize) return false; + if (!VoiceFrameHeader.TryRead(packet, out var candidate) || candidate.Type != MediaFrameType.Rebind) return false; + if (IsReplay(candidate.Sequence)) return false; + int aad = VoiceFrameHeader.Size + MediaEncryptor.RebindTokenSize; + if (!cipher.TryDecrypt(candidate.Sequence, packet[aad..], packet[..aad], [])) return false; + Accept(candidate.Sequence); + token = packet.Slice(VoiceFrameHeader.Size, MediaEncryptor.RebindTokenSize); + return true; + } + + private bool IsReplay(ulong sequence) + { + if (!initialized || sequence > highestSequence) return false; + ulong offset = highestSequence - sequence; + return offset >= 64 || (replayWindow & (1UL << (int)offset)) != 0; + } + + // Only authenticated counters may move the replay window. + private void Accept(ulong sequence) + { + if (!initialized) { highestSequence = sequence; replayWindow = 1; initialized = true; } else if (sequence > highestSequence) { ulong shift = sequence - highestSequence; @@ -46,9 +68,6 @@ public sealed class MediaDecryptor : IDisposable highestSequence = sequence; } else replayWindow |= 1UL << (int)(highestSequence - sequence); - header = candidate; - bytesWritten = length; - return true; } public void Dispose() diff --git a/src/VoiceCat.Crypto/MediaEncryptor.cs b/src/VoiceCat.Crypto/MediaEncryptor.cs index 9215c4e..71d70af 100644 --- a/src/VoiceCat.Crypto/MediaEncryptor.cs +++ b/src/VoiceCat.Crypto/MediaEncryptor.cs @@ -9,6 +9,8 @@ public sealed class MediaEncryptor : IDisposable private bool disposed; public const int TagSize = 16; + public const int RebindTokenSize = 16; + public const int RebindSize = VoiceFrameHeader.Size + RebindTokenSize + TagSize; public MediaEncryptor(ReadOnlySpan key) : this(key, false) { } @@ -31,6 +33,23 @@ public sealed class MediaEncryptor : IDisposable return size; } + // A rebind frame proves possession of the media key from a new source address. The token + // is plaintext so the relay can find the peer without trialling every key; it is covered by + // the AEAD as additional data, and the counter makes a captured frame unreplayable. + public int EncryptRebind(ReadOnlySpan token, Span packet) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (token.Length != RebindTokenSize) throw new ArgumentException("Binding tokens contain 16 bytes.", nameof(token)); + ArgumentOutOfRangeException.ThrowIfLessThan(packet.Length, RebindSize); + if (nextSequence == ulong.MaxValue) throw new InvalidOperationException("Media counter exhausted; establish a new session."); + var header = new VoiceFrameHeader(MediaFrameType.Rebind, VoiceFrameFlags.None, 0, 0, nextSequence++, 0); + header.Write(packet); + token.CopyTo(packet.Slice(VoiceFrameHeader.Size, RebindTokenSize)); + int aad = VoiceFrameHeader.Size + RebindTokenSize; + cipher.Encrypt(header.Sequence, [], packet[..aad], packet.Slice(aad, TagSize)); + return RebindSize; + } + public void Dispose() { if (disposed) return; diff --git a/src/VoiceCat.Protocol/VoiceFrameHeader.cs b/src/VoiceCat.Protocol/VoiceFrameHeader.cs index f8a9f51..3072585 100644 --- a/src/VoiceCat.Protocol/VoiceFrameHeader.cs +++ b/src/VoiceCat.Protocol/VoiceFrameHeader.cs @@ -6,7 +6,10 @@ public enum MediaFrameType : byte { Voice = 1, Keepalive = 2, - UdpBinding = 3 + UdpBinding = 3, + // Authenticated endpoint migration. Carries the peer's binding token in the clear for + // lookup only; the AEAD tag and replay window are what authorize the move. + Rebind = 4 } [Flags] diff --git a/src/VoiceCat.Server/Transport/MediaRelay.cs b/src/VoiceCat.Server/Transport/MediaRelay.cs index c76430d..923e907 100644 --- a/src/VoiceCat.Server/Transport/MediaRelay.cs +++ b/src/VoiceCat.Server/Transport/MediaRelay.cs @@ -4,6 +4,7 @@ using System.Net; using System.Net.Sockets; using System.Security.Cryptography; using System.Threading.Channels; +using VoiceCat.Crypto; using VoiceCat.Protocol; using PacketLossMode = Voicecat.V1.PacketLossMode; @@ -31,6 +32,7 @@ internal sealed class MediaRelay : IAsyncDisposable private readonly Channel changed = Channel.CreateBounded(1); private MediaRoute[] routes = []; private readonly byte[] input = new byte[65535]; + private readonly byte[] rebindAck = new byte[VoiceFrameHeader.Size]; private readonly MediaFanout fanout = new(); private readonly Task receiving; internal Task Completion => receiving; @@ -91,6 +93,30 @@ internal sealed class MediaRelay : IAsyncDisposable foreach (MediaRoute route in current) if (route.Peer.Endpoint?.Equals(sender) == true) { source = route; break; } + // A client whose media source address changed (a phone moving between Wi-Fi and + // cellular) keeps its TLS session but arrives here from an unknown address. The + // binding token alone travels in the clear, so it may only locate the peer; the + // authenticated tag and the peer's replay window are what authorize the move. + if (header.Type == MediaFrameType.Rebind) + { + if (length != MediaEncryptor.RebindSize) continue; + // Locate the peer by token before verifying, so a flood of forged rebinds + // costs one authentication attempt rather than one per connected peer. + MediaPeer? claimed = null; + foreach (MediaRoute route in current) + if (CryptographicOperations.FixedTimeEquals(route.Peer.Token, input.AsSpan(VoiceFrameHeader.Size, MediaEncryptor.RebindTokenSize))) + { claimed = route.Peer; break; } + if (claimed is null || !claimed.Crypto.Decryptor.TryVerifyRebind(input.AsSpan(0, length), out _)) continue; + var moved = new SocketAddress(sender.Family, sender.Size); + for (int index = 0; index < sender.Size; index++) moved[index] = sender[index]; + claimed.Endpoint = moved; + claimed.Activity.Touch(); + // Echo a keepalive so the client learns its new path is carrying media. + new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(rebindAck); + await SendAsync(rebindAck, sender).ConfigureAwait(false); + continue; + } + if (header.Type == MediaFrameType.UdpBinding) { if (length != VoiceFrameHeader.Size + 16 || source is not null) continue; diff --git a/tests/VoiceCat.Tests/AudioEngineTests.cs b/tests/VoiceCat.Tests/AudioEngineTests.cs index e58f068..c805a71 100644 --- a/tests/VoiceCat.Tests/AudioEngineTests.cs +++ b/tests/VoiceCat.Tests/AudioEngineTests.cs @@ -28,7 +28,7 @@ public class AudioEngineTests string source = File.ReadAllText(Path.Combine(FindRoot(), "src", "VoiceCat.Core", "ClientMediaTransport.cs")); Assert.Contains("new Thread(Send)", source); - Assert.Contains("socket.Send(packet.AsSpan(0, size)", source); + Assert.Contains("Volatile.Read(ref socket).Send(packet.AsSpan(0, size)", source); Assert.DoesNotContain("Task.Delay(5", source); Assert.DoesNotContain("SendAsync(packet.AsMemory", source); } @@ -253,8 +253,14 @@ public class AudioEngineTests [InlineData(5)] [InlineData(10)] [InlineData(20)] [InlineData(40)] [InlineData(60)] public void RecoveryLookaheadTracksChannelFrameDuration(int frameMilliseconds) { + // One frame of standing depth plus one of recovery lookahead. The depth floor keeps a + // quiet link from playing out with no buffer at all, where reordering alone conceals. using var stream = new ReceiveStream(2, Stream(frameMilliseconds)); - Assert.Equal(frameMilliseconds * 48, stream.TargetDepthSamples); + Assert.Equal(frameMilliseconds * 96, stream.TargetDepthSamples); + + StreamInfo plain = Stream(frameMilliseconds); plain.Audio.Fec = false; + using var bare = new ReceiveStream(2, plain); + Assert.Equal(frameMilliseconds * 48, bare.TargetDepthSamples); } [Fact] @@ -485,7 +491,7 @@ public class AudioEngineTests for (uint i = 0; i < 64; i++) stream.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 960), packet.AsSpan(0, length)); stream.Mix(output, false, null); Assert.InRange(stream.Depth, 0, 6); for (int i = 0; i < 20; i++) { output.AsSpan().Clear(); stream.Mix(output, false, null); } - Assert.All(output, value => Assert.Equal(0, value)); Assert.InRange(stream.ConcealedFrames, 1, 10); + Assert.All(output, value => Assert.Equal(0, value)); Assert.InRange(stream.ConcealedFrames, 1, 16); } [Fact] diff --git a/tests/VoiceCat.Tests/MediaRelayTests.cs b/tests/VoiceCat.Tests/MediaRelayTests.cs index 724b49c..ab270fc 100644 --- a/tests/VoiceCat.Tests/MediaRelayTests.cs +++ b/tests/VoiceCat.Tests/MediaRelayTests.cs @@ -1,6 +1,7 @@ using VoiceCat.Transport; using System.Net; using System.Net.Sockets; +using VoiceCat.Crypto; using VoiceCat.Protocol; using VoiceCat.Server.Transport; using Voicecat.V1; @@ -177,7 +178,7 @@ public sealed class MediaRelayTests internal sealed class VoicePeer : IAsyncDisposable { public Client Client { get; } - private readonly Socket udp = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + private Socket udp = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); private readonly IPEndPoint endpoint; private readonly MediaSessionCrypto crypto; public ulong LastSequence { get; private set; } @@ -228,6 +229,39 @@ public sealed class MediaRelayTests return packet; } public async Task SendAsync(byte[] packet) => await udp.SendToAsync(packet, SocketFlags.None, endpoint, Client.Timeout.Token); + + // Models a Wi-Fi/cellular handover: the peer keeps its TLS control session but its media + // source address changes, then it re-offers its UDP binding token from the new address. + internal async Task HandoverAsync() + { + udp.Dispose(); + udp = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + udp.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + byte[] rebind = new byte[MediaEncryptor.RebindSize]; + int size = crypto.Encryptor.EncryptRebind(Client.Authentication!.UdpToken.Span, rebind); + await SendAsync(rebind[..size]); + } + + // A rebind captured off the wire must not let anyone else claim the peer's downlink. + internal async Task CaptureRebindAsync() + { + byte[] rebind = new byte[MediaEncryptor.RebindSize]; + int size = crypto.Encryptor.EncryptRebind(Client.Authentication!.UdpToken.Span, rebind); + await SendAsync(rebind[..size]); + return rebind[..size]; + } + + // Returns true when the relay echoes a keepalive to the peer's current source address, + // which is the only signal that the server will route downlink media back to it. + internal async Task KeepaliveEchoesAsync(int timeoutMilliseconds = 1000) + { + byte[] keepalive = new byte[VoiceFrameHeader.Size]; + new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive); + await SendAsync(keepalive); + using var timeout = new CancellationTokenSource(timeoutMilliseconds); + try { byte[] buffer = new byte[65535]; await udp.ReceiveAsync(buffer, SocketFlags.None, timeout.Token); return true; } + catch (OperationCanceledException) { return false; } + } public async Task ReceivePacketAsync() { byte[] buffer = new byte[65535]; diff --git a/tests/VoiceCat.Tests/NetworkImpairmentTests.cs b/tests/VoiceCat.Tests/NetworkImpairmentTests.cs new file mode 100644 index 0000000..d2c7e88 --- /dev/null +++ b/tests/VoiceCat.Tests/NetworkImpairmentTests.cs @@ -0,0 +1,330 @@ +using System.Net; +using System.Net.Sockets; +using VoiceCat.Audio; +using VoiceCat.Core; +using VoiceCat.Codec; +using VoiceCat.Protocol; +using Voicecat.V1; +using Xunit; +using static VoiceCat.Tests.ServerTests; +using Xunit.Abstractions; + +namespace VoiceCat.Tests; + +// Deterministic network-impairment simulation for the receive path. A virtual millisecond clock +// drives an encoder, an impairment model, and the mixer so bursty loss, jitter, reordering, +// link outages, and a stalled (backgrounded) consumer are reproducible without hardware. +public class NetworkImpairmentTests(ITestOutputHelper output) +{ + // A scheduled packet. Arrival is virtual-clock milliseconds; duplicates share a timestamp. + private readonly record struct Wire(int Arrival, uint Sequence, uint Timestamp, bool Marker, int Length, byte[] Payload); + + private sealed class Impairment(int seed) + { + private readonly Random random = new(seed); + private bool bursting; + internal double LossPercent, BurstLossPercent, BurstEntryPercent, BurstExitPercent = 30, JitterMs, ReorderPercent, DuplicatePercent; + internal int BaseDelayMs = 20, OutageStartMs = -1, OutageEndMs = -1; + + internal bool Dropped(int sendTime) + { + if (OutageStartMs >= 0 && sendTime >= OutageStartMs && sendTime < OutageEndMs) return true; + if (BurstEntryPercent > 0) + { + bursting = bursting ? random.NextDouble() * 100 >= BurstExitPercent : random.NextDouble() * 100 < BurstEntryPercent; + if (bursting) return random.NextDouble() * 100 < BurstLossPercent; + } + return random.NextDouble() * 100 < LossPercent; + } + + internal int Arrival(int sendTime) + { + double delay = BaseDelayMs + (JitterMs > 0 ? random.NextDouble() * JitterMs : 0); + if (ReorderPercent > 0 && random.NextDouble() * 100 < ReorderPercent) delay += 45; + return sendTime + (int)delay; + } + + internal bool Duplicated() => DuplicatePercent > 0 && random.NextDouble() * 100 < DuplicatePercent; + } + + private sealed record Report(string Name, int Frames, int SilentFrames, int LongestSilentRunMs, int Concealed, int Overruns, int Sent, int TargetDepth) + { + internal double SilentPercent => Frames == 0 ? 0 : SilentFrames * 100.0 / Frames; + public override string ToString() => + $"{Name,-28} silent={SilentPercent,5:F1}% worstGap={LongestSilentRunMs,5}ms concealed={Concealed,4} overruns={Overruns,3} sent={Sent,4} target={TargetDepth,4}"; + } + + // Runs `durationMs` of a 20 ms mono talkspurt through the impairment model. The consumer + // pumps the mixer every 20 ms of virtual time except inside a stall window, which models an + // iOS render callback that stops being serviced while backgrounded. + private Report Simulate(string name, Impairment impairment, int durationMs = 20_000, bool dred = false, bool fec = true, + int stallStartMs = -1, int stallEndMs = -1) + { + StreamInfo info = AudioEngineTests.Stream(20, dred: dred); + info.Audio.Fec = fec; + var clock = new VirtualClock(); + using var stream = new ReceiveStream(7, info, clock); + using var encoder = new OpusEncoder(new() { Bitrate = 32000, ForwardErrorCorrection = fec, DeepRedundancy = dred, ExpectedPacketLossPercent = 20, Complexity = 5 }); + var pending = new List(); + short[] tone = new short[960]; + int frames = 0, silent = 0, run = 0, longest = 0, sent = 0; + int[] mix = new int[1920]; + + for (int now = 0; now <= durationMs; now += 20) + { + CodecTests.FillTone(tone, 960, 1, 48000, now / 20); + byte[] packet = new byte[1275]; + int length = encoder.Encode(tone, packet); + uint sequence = (uint)(now / 20); + if (!impairment.Dropped(now)) + { + pending.Add(new(impairment.Arrival(now), sequence, sequence * 960, sequence == 0, length, packet)); + if (impairment.Duplicated()) pending.Add(new(impairment.Arrival(now) + 5, sequence, sequence * 960, false, length, packet)); + } + + clock.Set(now); + foreach (Wire wire in pending.Where(w => w.Arrival <= now).OrderBy(w => w.Arrival).ToArray()) + { + var flags = wire.Marker ? VoiceFrameFlags.Marker : VoiceFrameFlags.None; + sent++; + stream.Enqueue(new(MediaFrameType.Voice, flags, 0, 42, wire.Sequence, wire.Timestamp), wire.Payload.AsSpan(0, wire.Length)); + pending.Remove(wire); + } + + if (stallStartMs >= 0 && now >= stallStartMs && now < stallEndMs) continue; + + mix.AsSpan().Clear(); + stream.Mix(mix, false, null); + frames++; + bool quiet = true; + foreach (int sample in mix) if (sample != 0) { quiet = false; break; } + if (quiet) { silent++; run += 20; longest = Math.Max(longest, run); } + else run = 0; + } + var report = new Report(name, frames, silent, longest, stream.ConcealedFrames, stream.Overruns, sent, stream.TargetDepthSamples); + output.WriteLine(report.ToString()); + return report; + } + + [Fact] + public void ImpairmentProfileReport() + { + output.WriteLine("--- FEC on ---"); + Simulate("clean", new Impairment(1)); + Simulate("random loss 2%", new Impairment(2) { LossPercent = 2 }); + Simulate("random loss 10%", new Impairment(3) { LossPercent = 10 }); + Simulate("bursty loss", new Impairment(4) { BurstEntryPercent = 4, BurstLossPercent = 80, BurstExitPercent = 25 }); + Simulate("jitter 60ms", new Impairment(5) { JitterMs = 60 }); + Simulate("jitter 120ms", new Impairment(6) { JitterMs = 120 }); + Simulate("reorder 5%", new Impairment(7) { ReorderPercent = 5 }); + Simulate("duplicate 5%", new Impairment(8) { DuplicatePercent = 5 }); + Simulate("wifi switch 3s outage", new Impairment(9) { OutageStartMs = 6000, OutageEndMs = 9000 }); + Simulate("bad wifi (loss+jitter)", new Impairment(10) { LossPercent = 8, JitterMs = 80, ReorderPercent = 3 }); + Simulate("background stall 2s", new Impairment(11), stallStartMs: 6000, stallEndMs: 8000); + Simulate("background stall 10s", new Impairment(13), stallStartMs: 6000, stallEndMs: 16000); + Simulate("stall + jitter", new Impairment(12) { JitterMs = 60 }, stallStartMs: 6000, stallEndMs: 8000); + + output.WriteLine("--- FEC off and DRED off ---"); + Simulate("nofec clean", new Impairment(21), fec: false); + Simulate("nofec loss 2%", new Impairment(22) { LossPercent = 2 }, fec: false); + Simulate("nofec loss 10%", new Impairment(23) { LossPercent = 10 }, fec: false); + Simulate("nofec jitter 30ms", new Impairment(24) { JitterMs = 30 }, fec: false); + Simulate("nofec reorder 5%", new Impairment(25) { ReorderPercent = 5 }, fec: false); + Simulate("nofec bad wifi", new Impairment(26) { LossPercent = 8, JitterMs = 80, ReorderPercent = 3 }, fec: false); + } + + // Bounds are set well above the measured result so ordinary codec variation does not make + // them flaky; they exist to catch a structural regression in the receive path, such as the + // depth floor or the arrival estimator being lost again. + [Theory] + // impairment, maxConcealedPercent, maxGapMs + [InlineData("loss2", 12)] + [InlineData("loss10", 12)] + [InlineData("burst", 20)] + [InlineData("jitter60", 12)] + [InlineData("jitter120", 12)] + [InlineData("reorder", 5)] + [InlineData("badwifi", 12)] + public void ImpairedLinksStayIntelligible(string profile, int maxConcealedPercent) + { + Impairment impairment = profile switch + { + "loss2" => new(2) { LossPercent = 2 }, + "loss10" => new(3) { LossPercent = 10 }, + "burst" => new(4) { BurstEntryPercent = 4, BurstLossPercent = 80, BurstExitPercent = 25 }, + "jitter60" => new(5) { JitterMs = 60 }, + "jitter120" => new(6) { JitterMs = 120 }, + "reorder" => new(7) { ReorderPercent = 5 }, + _ => new(10) { LossPercent = 8, JitterMs = 80, ReorderPercent = 3 }, + }; + Report report = Simulate(profile, impairment); + Assert.True(report.Concealed * 100 / report.Frames <= maxConcealedPercent, + $"{profile} concealed {report.Concealed} of {report.Frames} frames."); + Assert.True(report.LongestSilentRunMs <= 200, $"{profile} went silent for {report.LongestSilentRunMs} ms."); + } + + // Pure reordering loses no data at all, so it must be absorbed by depth rather than concealed. + // Without the depth floor and an estimator that observes late arrivals this was 47 frames. + [Fact] + public void ReorderingWithoutLossIsAbsorbedRatherThanConcealed() + { + Report report = Simulate("reorder no fec", new Impairment(25) { ReorderPercent = 5 }, fec: false); + Assert.Equal(1000, report.Sent); + Assert.True(report.Concealed <= 15, $"Concealed {report.Concealed} frames despite losing none."); + } + + // A consumer that stops draining (an interrupted or rebuilding iOS graph) must not cost the + // live talkspurt. The handoff previously refused new packets while full, discarding 437 of + // 1000 packets across a ten second stall. + [Fact] + public void AStalledConsumerLosesBoundedAudioRatherThanTheLiveTalkspurt() + { + Report report = Simulate("stall", new Impairment(13), stallStartMs: 6000, stallEndMs: 16000); + Assert.Equal(1000, report.Sent); + Assert.InRange(report.Overruns, 1, 4); + Assert.True(report.LongestSilentRunMs <= 200, $"Silent for {report.LongestSilentRunMs} ms after the stall."); + } + + // A Wi-Fi/cellular handover changes the client's media source address while TLS survives. + // The relay binds a peer's endpoint once and refuses to move it, and the client stops + // offering its binding token after the first bind, so media must not silently die here. + [Fact] + public async Task MediaSurvivesAHandoverThatChangesTheClientSourceAddress() + { + await using var fixture = new ServerFixture(); + await using var alice = await MediaRelayTests.VoicePeer.ConnectAsync(fixture, "Alice"); + await using var bob = await MediaRelayTests.VoicePeer.ConnectAsync(fixture, "Bob"); + StreamAnnounceResult stream = await alice.AnnounceAsync(StreamKind.StreamMic); + + await alice.SendAsync(alice.Seal(stream.Ssrc, [1])); + await bob.ReceiveVoiceAsync(); + + await alice.HandoverAsync(); + Assert.True(await alice.KeepaliveEchoesAsync(), "Relay stopped routing to Alice after her media source address changed."); + + await alice.SendAsync(alice.Seal(stream.Ssrc, [2])); + (_, byte[] payload) = await bob.ReceiveVoiceAsync(); + Assert.Equal([2], payload); + } + + // The rebind token travels in the clear so the relay can locate the peer without trialling + // every key. Authorization comes from the AEAD tag and the replay window, so an on-path + // observer who captures a rebind must not be able to redirect the peer's downlink. + [Fact] + public async Task ReplayedRebindFromAnotherAddressCannotStealTheDownlink() + { + await using var fixture = new ServerFixture(); + await using var alice = await MediaRelayTests.VoicePeer.ConnectAsync(fixture, "Alice"); + await using var bob = await MediaRelayTests.VoicePeer.ConnectAsync(fixture, "Bob"); + StreamAnnounceResult stream = await alice.AnnounceAsync(StreamKind.StreamMic); + + byte[] captured = await alice.CaptureRebindAsync(); + Assert.True(await alice.KeepaliveEchoesAsync()); + + using var attacker = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + attacker.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + await attacker.SendToAsync(captured, SocketFlags.None, fixture.Server.MediaEndPoint); + using (var timeout = new CancellationTokenSource(500)) + { + byte[] buffer = new byte[65535]; + await Assert.ThrowsAnyAsync(async () => + await attacker.ReceiveAsync(buffer, SocketFlags.None, timeout.Token)); + } + + // Alice still owns the path, so her media keeps flowing to Bob. + await alice.SendAsync(alice.Seal(stream.Ssrc, [9])); + (_, byte[] payload) = await bob.ReceiveVoiceAsync(); + Assert.Equal([9], payload); + Assert.True(await alice.KeepaliveEchoesAsync()); + } + + // A phone that changes interface leaves TCP blackholed rather than reset: the socket stays + // open and the OS reports nothing for minutes. Only an unanswered keepalive exposes it, and + // until it does the app shows a live session over a dead path and never reconnects. + [Fact] + public async Task ABlackholedControlConnectionIsDetectedInsteadOfAppearingConnected() + { + await using var fixture = new ServerFixture(); + await using var proxy = new BlackholeProxy(fixture.Server.EndPoint); + await using var client = new VoiceCatClient("Test", "0.0.1", Path.Combine(fixture.Directory, "tofu.txt")); + client.SetControlLiveness(TimeSpan.FromMilliseconds(200), TimeSpan.FromSeconds(2)); + + var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.ConnectionStateChanged += state => { if (state == ClientConnectionState.Disconnected) disconnected.TrySetResult(); }; + await client.ConnectAsync("127.0.0.1", (ushort)proxy.EndPoint.Port, (_, _) => ValueTask.FromResult(true)); + await client.AuthenticateGuestAsync("Alice"); + Assert.Equal(ClientConnectionState.Connected, client.State); + + proxy.Freeze(); + await disconnected.Task.WaitAsync(TimeSpan.FromSeconds(15)); + Assert.Equal(ClientConnectionState.Disconnected, client.State); + Assert.IsType(client.ConnectionFailure); + } + + // Forwards TCP both ways until frozen, after which bytes are swallowed and the sockets are + // left open — what a vanished route looks like to the client, unlike a close or a reset. + private sealed class BlackholeProxy : IAsyncDisposable + { + private readonly Socket listener = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + private readonly CancellationTokenSource stop = new(); + private readonly IPEndPoint origin; + private volatile bool frozen; + internal IPEndPoint EndPoint { get; } + internal void Freeze() => frozen = true; + + internal BlackholeProxy(IPEndPoint origin) + { + this.origin = origin; + listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + listener.Listen(4); + EndPoint = (IPEndPoint)listener.LocalEndPoint!; + _ = AcceptAsync(); + } + + private async Task AcceptAsync() + { + try + { + while (!stop.IsCancellationRequested) + { + Socket inbound = await listener.AcceptAsync(stop.Token); + Socket outbound = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await outbound.ConnectAsync(origin, stop.Token); + _ = PumpAsync(inbound, outbound); + _ = PumpAsync(outbound, inbound); + } + } + catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException) { } + } + + private async Task PumpAsync(Socket from, Socket to) + { + byte[] buffer = new byte[16384]; + try + { + while (!stop.IsCancellationRequested) + { + int length = await from.ReceiveAsync(buffer, SocketFlags.None, stop.Token); + if (length == 0) break; + if (frozen) continue; + await to.SendAsync(buffer.AsMemory(0, length), SocketFlags.None, stop.Token); + } + } + catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException) { } + } + + public ValueTask DisposeAsync() + { + stop.Cancel(); listener.Dispose(); stop.Dispose(); + return ValueTask.CompletedTask; + } + } + + private sealed class VirtualClock : TimeProvider + { + private long milliseconds; + public override long TimestampFrequency => 1000; + public override long GetTimestamp() => milliseconds; + internal void Set(long value) => milliseconds = value; + } +}