From c16163a82f6dc6e75bb7589363ca3fd8df615dbf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 19 Jul 2026 17:57:26 +0100 Subject: [PATCH] Modularize CultMesh transport planes --- src/GameCult.Mesh/CultMeshClient.cs | 37 +- src/GameCult.Mesh/CultMeshContentSessions.cs | 209 +++++--- src/GameCult.Mesh/CultMeshContentTransfer.cs | 190 +++++-- .../CultMeshContentTransports.cs | 166 +++++++ .../CultMeshRealtimeTransports.cs | 154 ++++++ src/GameCult.Mesh/CultMeshSessions.cs | 462 +++++++++++++++++- .../CultMeshTcpContentTransport.cs | 421 ++++++++++++++++ .../docs/reliability-migration-plan.md | 13 +- src/GameCult.Mesh/docs/transport-planes.md | 74 +++ .../CultNetSchemaClient.cs | 11 +- .../CultNetShardWriteForwarding.cs | 6 +- .../CultNetTcpSchemaTransport.cs | 217 ++++++++ .../CultMeshBodySessionTests.cs | 1 + .../CultMeshClientTests.cs | 2 + .../CultMeshContentSessionTests.cs | 35 +- .../CultMeshContentTransferTests.cs | 9 +- .../CultMeshSessionManagerTests.cs | 1 + .../CultMeshTransportModularityTests.cs | 272 +++++++++++ tools/GameCult.TransportParity/Program.cs | 53 +- 19 files changed, 2162 insertions(+), 171 deletions(-) create mode 100644 src/GameCult.Mesh/CultMeshContentTransports.cs create mode 100644 src/GameCult.Mesh/CultMeshRealtimeTransports.cs create mode 100644 src/GameCult.Mesh/CultMeshTcpContentTransport.cs create mode 100644 src/GameCult.Mesh/docs/transport-planes.md create mode 100644 src/GameCult.Networking/CultNetTcpSchemaTransport.cs create mode 100644 tests/GameCult.Mesh.Tests/CultMeshTransportModularityTests.cs diff --git a/src/GameCult.Mesh/CultMeshClient.cs b/src/GameCult.Mesh/CultMeshClient.cs index 2be64288..12ccd470 100644 --- a/src/GameCult.Mesh/CultMeshClient.cs +++ b/src/GameCult.Mesh/CultMeshClient.cs @@ -22,9 +22,22 @@ namespace GameCult.Mesh /// Gets or sets session path and diagnostic policy. public CultMeshSessionManagerOptions Sessions { get; set; } = new(); - /// Gets or sets optional transport connectors. The CultNet schema connector is used by default. + /// Gets or sets schema connectors. Length-prefixed TCP is used by default. public IReadOnlyList? Connectors { get; set; } + /// + /// Gets or sets streaming content connectors. TCP content delivery is used by default; + /// applications may replace it or explicitly add legacy RUDP. + /// + public IReadOnlyList? ContentConnectors { get; set; } + + /// + /// Gets or sets realtime state connectors. No connector is installed implicitly; + /// promoted runtimes should register QUIC and may explicitly add a legacy fallback. + /// + public IReadOnlyList RealtimeConnectors { get; set; } = + Array.Empty(); + /// Gets or sets the deadline before an unanswered subscription intent is replayed. public TimeSpan SubscriptionResponseTimeout { get; set; } = TimeSpan.FromSeconds(2); } @@ -68,8 +81,13 @@ namespace GameCult.Mesh _discovery, options.Connectors ?? new ICultMeshTransportConnector[] { - new CultMeshSchemaTransportConnector(clock: options.Sessions.Clock) + new CultMeshTcpSchemaTransportConnector(clock: options.Sessions.Clock) }, + options.ContentConnectors ?? new ICultMeshContentTransportConnector[] + { + new CultMeshTcpContentTransportConnector() + }, + options.RealtimeConnectors, options.Sessions); _subscriptionResponseTimeout = options.SubscriptionResponseTimeout; } @@ -84,6 +102,15 @@ namespace GameCult.Mesh return _sessions.ConnectAsync(CultMeshEndpointId.Parse(endpointId), protocol, cancellationToken); } + /// Connects the reusable realtime state plane by stable endpoint identity. + public Task ConnectRealtimeAsync( + string endpointId, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + return _sessions.ConnectRealtimeAsync(CultMeshEndpointId.Parse(endpointId), cancellationToken); + } + /// Connects to a stable endpoint identity using one application protocol. public Task ConnectAsync( CultMeshEndpointId endpointId, @@ -97,15 +124,13 @@ namespace GameCult.Mesh /// Creates a verified-transfer provider over this client's reusable content session owner. public CultMeshSessionContentProvider ContentProvider( string providerId, - string endpointId, - CultMeshSessionContentProviderOptions? options = null) + string endpointId) { ThrowIfDisposed(); return new CultMeshSessionContentProvider( providerId, _sessions, - CultMeshEndpointId.Parse(endpointId), - options); + CultMeshEndpointId.Parse(endpointId)); } /// Creates a direct body provider over this client's reusable negotiated session owner. diff --git a/src/GameCult.Mesh/CultMeshContentSessions.cs b/src/GameCult.Mesh/CultMeshContentSessions.cs index 99687358..51570c54 100644 --- a/src/GameCult.Mesh/CultMeshContentSessions.cs +++ b/src/GameCult.Mesh/CultMeshContentSessions.cs @@ -11,7 +11,7 @@ namespace GameCult.Mesh /// Serves bounded, content-addressed CDN chunks over the CultMesh content protocol. /// Manifests and descriptors remain typed document state; payload bytes do not. /// - public sealed class CultMeshContentServer : IDisposable + public sealed class CultMeshLegacyRudpContentServer : IDisposable { private readonly ICultNetSchemaServer _server; private readonly CultCache _content; @@ -20,7 +20,7 @@ namespace GameCult.Mesh private bool _disposed; /// Attaches content serving to an existing schema host and provider cache. - public CultMeshContentServer( + public CultMeshLegacyRudpContentServer( ICultNetSchemaServer server, CultCache content, Func? canServeHash = null) @@ -85,8 +85,8 @@ namespace GameCult.Mesh } } - /// Configures bounded response waiting for session-backed content requests. - public sealed class CultMeshSessionContentProviderOptions + /// Configures the explicit compatibility path for content over schema-message RUDP. + public sealed class CultMeshLegacyRudpContentTransportOptions { /// Gets or sets the injected reliability clock. public ICultMeshClock Clock { get; set; } = CultMeshSystemClock.Instance; @@ -95,97 +95,148 @@ namespace GameCult.Mesh } /// - /// Retrieves verified CDN chunks through one reusable identity-first CultMesh session. - /// Transfer verification and final cache promotion remain owned by CultMeshContentTransferService. + /// Explicit low-priority compatibility connector for the retired content-over-RUDP path. + /// It is never installed by default. /// - public sealed class CultMeshSessionContentProvider : ICultMeshContentProvider + public sealed class CultMeshLegacyRudpContentTransportConnector : ICultMeshContentTransportConnector { - private readonly CultMeshSessionManager _sessions; - private readonly CultMeshEndpointId _endpointId; - private readonly CultMeshSessionContentProviderOptions _options; + public const int LegacyPriority = 10_000; + private readonly CultMeshSchemaTransportConnector _schemaConnector; + private readonly CultMeshLegacyRudpContentTransportOptions _options; - /// Creates a content provider backed by a reusable identity-first session. - public CultMeshSessionContentProvider( - string providerId, - CultMeshSessionManager sessions, - CultMeshEndpointId endpointId, - CultMeshSessionContentProviderOptions? options = null) + public CultMeshLegacyRudpContentTransportConnector( + CultMeshLegacyRudpContentTransportOptions? options = null, + Func? createClient = null) { - ProviderId = string.IsNullOrWhiteSpace(providerId) - ? throw new ArgumentException("Provider identity is required.", nameof(providerId)) - : providerId; - _sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); - _endpointId = endpointId ?? throw new ArgumentNullException(nameof(endpointId)); - _options = options ?? new CultMeshSessionContentProviderOptions(); + _options = options ?? new CultMeshLegacyRudpContentTransportOptions(); if (_options.ResponseTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(options), "Content response timeout must be positive."); + _schemaConnector = new CultMeshSchemaTransportConnector(createClient, _options.Clock, _options.ResponseTimeout); } - /// - public string ProviderId { get; } + public string ConnectorId => "legacy-rudp-content"; + public int Priority => LegacyPriority; + public bool CanConnect(CultMeshTransportCandidate candidate) => + Uri.TryCreate(candidate.Endpoint, UriKind.Absolute, out var uri) && + string.Equals(uri.Scheme, "rudp", StringComparison.OrdinalIgnoreCase); - /// - public async Task GetChunkAsync( - CultMeshCdnChunkRef chunk, + public async Task ConnectAsync( + CultMeshTransportCandidate candidate, + CultMeshEndpointId endpointId, CancellationToken cancellationToken = default) { - if (chunk == null) throw new ArgumentNullException(nameof(chunk)); - var hash = CultMeshCdn.NormalizeHash(chunk.ChunkHash, nameof(chunk.ChunkHash)); - var session = await _sessions.ConnectAsync( - _endpointId, - CultMeshProtocols.Content, - cancellationToken).ConfigureAwait(false); - var messageId = Guid.NewGuid().ToString("N"); - var completion = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - using var responseSubscription = session.OnCultNet(response => - { - if (string.Equals(response.MessageId, messageId, StringComparison.Ordinal)) - completion.TrySetResult(response); - }); - using var errorSubscription = session.OnCultNet(error => - completion.TrySetException(new IOException(error.Error))); - session.SendCultNet(new CultMeshContentChunkRequestMessage - { - MessageId = messageId, - ChunkHash = hash, - RecordKey = string.IsNullOrWhiteSpace(chunk.RecordKey) - ? CultMeshCdnArtifactChunk.CreateRecordKey(hash).Value - : chunk.RecordKey, - ExpectedSizeBytes = chunk.SizeBytes - }); - - var response = await WaitForResponseAsync(completion.Task, cancellationToken).ConfigureAwait(false); - if (!response.Found) - throw new FileNotFoundException( - "Content provider '" + ProviderId + "' rejected chunk '" + hash + "': " + response.Error, - chunk.RecordKey); - var resolved = new CultMeshCdnArtifactChunk - { - ChunkHash = response.ChunkHash, - SizeBytes = response.SizeBytes, - Payload = response.Payload ?? Array.Empty() - }; - CultMeshCdn.ValidateChunkPayload(chunk, resolved); - return resolved; + if (!CanConnect(candidate)) + throw new NotSupportedException($"Legacy RUDP content connector does not support '{candidate.Endpoint}'."); + var client = await _schemaConnector.ConnectAsync(candidate, CultMeshProtocols.Content, cancellationToken) + .ConfigureAwait(false); + return new LegacyRudpContentTransport(candidate.Endpoint, client, _options); } - private async Task WaitForResponseAsync( - Task response, - CancellationToken cancellationToken) + private sealed class LegacyRudpContentTransport : ICultMeshContentTransport { - using var deadlineCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var deadline = _options.Clock.DelayAsync(_options.ResponseTimeout, deadlineCancellation.Token); - var completed = await Task.WhenAny(response, deadline).ConfigureAwait(false); - if (completed == response) + private readonly ICultNetSchemaClient _client; + private readonly CultMeshLegacyRudpContentTransportOptions _options; + private readonly System.Collections.Concurrent.ConcurrentDictionary> _pending = new(StringComparer.Ordinal); + private bool _disposed; + + public LegacyRudpContentTransport( + string endpoint, + ICultNetSchemaClient client, + CultMeshLegacyRudpContentTransportOptions options) { - deadlineCancellation.Cancel(); - return await response.ConfigureAwait(false); + Endpoint = endpoint; + _client = client; + _options = options; + _client.OnCultNet(OnResponse); + _client.OnCultNet(OnError); + } + + public string TransportId => "legacy-rudp-content"; + public string Endpoint { get; } + + public async Task CopyChunkToAsync( + CultMeshCdnChunkRef chunk, + Stream destination, + CancellationToken cancellationToken = default) + { + if (_disposed) throw new ObjectDisposedException(nameof(LegacyRudpContentTransport)); + if (chunk == null) throw new ArgumentNullException(nameof(chunk)); + if (destination == null) throw new ArgumentNullException(nameof(destination)); + var hash = CultMeshCdn.NormalizeHash(chunk.ChunkHash, nameof(chunk.ChunkHash)); + var messageId = Guid.NewGuid().ToString("N"); + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + if (!_pending.TryAdd(messageId, completion)) + throw new InvalidOperationException("Duplicate legacy content request identity."); + try + { + _client.SendCultNet(new CultMeshContentChunkRequestMessage + { + MessageId = messageId, + ChunkHash = hash, + RecordKey = string.IsNullOrWhiteSpace(chunk.RecordKey) + ? CultMeshCdnArtifactChunk.CreateRecordKey(hash).Value + : chunk.RecordKey, + ExpectedSizeBytes = chunk.SizeBytes + }); + var response = await WaitForResponseAsync(completion.Task, cancellationToken).ConfigureAwait(false); + if (!response.Found) + throw new FileNotFoundException( + "Legacy RUDP content provider rejected chunk '" + hash + "': " + response.Error, + chunk.RecordKey); + var resolved = new CultMeshCdnArtifactChunk + { + ChunkHash = response.ChunkHash, + SizeBytes = response.SizeBytes, + Payload = response.Payload ?? Array.Empty() + }; + CultMeshCdn.ValidateChunkPayload(chunk, resolved); + await destination.WriteAsync(resolved.Payload, 0, resolved.Payload.Length, cancellationToken) + .ConfigureAwait(false); + } + finally + { + _pending.TryRemove(messageId, out _); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + foreach (var completion in _pending.Values) + completion.TrySetException(new ObjectDisposedException(nameof(LegacyRudpContentTransport))); + _pending.Clear(); + _client.Dispose(); + } + + private void OnResponse(CultMeshContentChunkResponseMessage response) + { + if (_pending.TryGetValue(response.MessageId ?? string.Empty, out var completion)) + completion.TrySetResult(response); + } + + private void OnError(CultNetErrorMessage error) + { + var exception = new IOException(error.Error); + foreach (var completion in _pending.Values) completion.TrySetException(exception); + } + + private async Task WaitForResponseAsync( + Task response, + CancellationToken cancellationToken) + { + using var deadlineCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var deadline = _options.Clock.DelayAsync(_options.ResponseTimeout, deadlineCancellation.Token); + var completed = await Task.WhenAny(response, deadline).ConfigureAwait(false); + if (completed == response) + { + deadlineCancellation.Cancel(); + return await response.ConfigureAwait(false); + } + cancellationToken.ThrowIfCancellationRequested(); + throw new TimeoutException("Timed out fetching content through the legacy RUDP connector."); } - cancellationToken.ThrowIfCancellationRequested(); - throw new TimeoutException( - "Timed out fetching content from provider '" + ProviderId + "' through endpoint identity '" + - _endpointId.Value + "'."); } } } diff --git a/src/GameCult.Mesh/CultMeshContentTransfer.cs b/src/GameCult.Mesh/CultMeshContentTransfer.cs index 59f89a15..f6a426b8 100644 --- a/src/GameCult.Mesh/CultMeshContentTransfer.cs +++ b/src/GameCult.Mesh/CultMeshContentTransfer.cs @@ -32,7 +32,10 @@ namespace GameCult.Mesh public interface ICultMeshContentProvider { string ProviderId { get; } - Task GetChunkAsync(CultMeshCdnChunkRef chunk, CancellationToken cancellationToken = default); + Task CopyChunkToAsync( + CultMeshCdnChunkRef chunk, + Stream destination, + CancellationToken cancellationToken = default); } public sealed class CultMeshDatabaseContentProvider : ICultMeshContentProvider @@ -47,15 +50,23 @@ namespace GameCult.Mesh public string ProviderId { get; } - public async Task GetChunkAsync(CultMeshCdnChunkRef chunk, CancellationToken cancellationToken = default) + public async Task CopyChunkToAsync( + CultMeshCdnChunkRef chunk, + Stream destination, + CancellationToken cancellationToken = default) { + if (chunk == null) throw new ArgumentNullException(nameof(chunk)); + if (destination == null) throw new ArgumentNullException(nameof(destination)); cancellationToken.ThrowIfCancellationRequested(); var key = string.IsNullOrWhiteSpace(chunk.RecordKey) ? CultMeshCdnArtifactChunk.CreateRecordKey(chunk.ChunkHash) : new CultRecordKey(chunk.RecordKey); var result = await _database.GetAsync(key).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); - return result; + if (result == null) + throw new FileNotFoundException("CDN artifact chunk is missing from the provider.", key.Value); + CultMeshCdn.ValidateChunkPayload(chunk, result); + await destination.WriteAsync(result.Payload, 0, result.Payload.Length, cancellationToken).ConfigureAwait(false); } } @@ -218,44 +229,51 @@ namespace GameCult.Mesh var state = _stateCache.Get(stateKey); var verified = RestoreVerifiedState(state, manifest, fingerprint, partialPath); - using (var stream = new FileStream(partialPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read, 81920, FileOptions.Asynchronous)) + using (var stream = new FileStream( + partialPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.ReadWrite, + 81920, + FileOptions.Asynchronous)) { stream.SetLength(manifest.SizeBytes); - var ordered = manifest.Chunks - .Select((chunk, index) => new { Chunk = chunk, Index = index }) - .OrderBy(item => item.Chunk.Offset) - .ToArray(); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } - var remaining = ordered.Where(item => !verified.Contains(item.Index)).ToArray(); - for (var windowStart = 0; windowStart < remaining.Length; windowStart += _maxConcurrentChunkRequests) + var ordered = manifest.Chunks + .Select((chunk, index) => new { Chunk = chunk, Index = index }) + .OrderBy(item => item.Chunk.Offset) + .ToArray(); + var remaining = ordered.Where(item => !verified.Contains(item.Index)).ToArray(); + for (var windowStart = 0; windowStart < remaining.Length; windowStart += _maxConcurrentChunkRequests) + { + cancellationToken.ThrowIfCancellationRequested(); + var window = remaining + .Skip(windowStart) + .Take(_maxConcurrentChunkRequests) + .Select(item => (Item: item, Copy: CopyVerifiedChunkToPartialAsync( + item.Chunk, + partialPath, + cancellationToken))) + .ToArray(); + try { - cancellationToken.ThrowIfCancellationRequested(); - var window = remaining - .Skip(windowStart) - .Take(_maxConcurrentChunkRequests) - .Select(item => (Item: item, Fetch: FetchVerifiedChunkAsync(item.Chunk, cancellationToken))) - .ToArray(); - try + foreach (var pending in window) { - foreach (var pending in window) - { - var chunk = await pending.Fetch.ConfigureAwait(false); - stream.Position = pending.Item.Chunk.Offset; - await stream.WriteAsync(chunk.Payload, 0, chunk.Payload.Length, cancellationToken).ConfigureAwait(false); - await stream.FlushAsync(cancellationToken).ConfigureAwait(false); - verified.Add(pending.Item.Index); - await SaveStateAsync(contentHash, fingerprint, manifest.SizeBytes, verified, stateKey).ConfigureAwait(false); - } + await pending.Copy.ConfigureAwait(false); + verified.Add(pending.Item.Index); + await SaveStateAsync(contentHash, fingerprint, manifest.SizeBytes, verified, stateKey).ConfigureAwait(false); } - catch + } + catch + { + foreach (var pending in window) { - foreach (var pending in window) - { - try { await pending.Fetch.ConfigureAwait(false); } - catch { } - } - throw; + try { await pending.Copy.ConfigureAwait(false); } + catch { } } + throw; } } @@ -307,8 +325,9 @@ namespace GameCult.Mesh return verified; } - private async Task FetchVerifiedChunkAsync( + private async Task CopyVerifiedChunkToPartialAsync( CultMeshCdnChunkRef reference, + string partialPath, CancellationToken cancellationToken) { var failures = new List(); @@ -317,10 +336,22 @@ namespace GameCult.Mesh cancellationToken.ThrowIfCancellationRequested(); try { - var chunk = await provider.GetChunkAsync(reference, cancellationToken).ConfigureAwait(false) - ?? throw new FileNotFoundException("CDN artifact chunk is missing from the provider.", reference.RecordKey); - CultMeshCdn.ValidateChunkPayload(reference, chunk); - return chunk; + using var partial = new FileStream( + partialPath, + FileMode.Open, + FileAccess.Write, + FileShare.ReadWrite, + 81920, + FileOptions.Asynchronous | FileOptions.RandomAccess); + partial.Position = reference.Offset; + using var destination = new BoundedHashingWriteStream(partial, reference.SizeBytes); + await provider.CopyChunkToAsync(reference, destination, cancellationToken).ConfigureAwait(false); + await destination.FlushAsync(cancellationToken).ConfigureAwait(false); + var actualHash = destination.CompleteHash(); + var expectedHash = CultMeshCdn.NormalizeHash(reference.ChunkHash, nameof(reference.ChunkHash)); + if (!string.Equals(actualHash, expectedHash, StringComparison.Ordinal)) + throw new InvalidDataException("CDN artifact chunk hash does not match its manifest."); + return; } catch (Exception error) when (!(error is OperationCanceledException && cancellationToken.IsCancellationRequested)) { @@ -331,6 +362,89 @@ namespace GameCult.Mesh throw new AggregateException("No content provider supplied a valid CDN chunk.", failures); } + private sealed class BoundedHashingWriteStream : Stream + { + private readonly Stream _destination; + private readonly long _expectedBytes; + private readonly SHA256 _sha = SHA256.Create(); + private long _written; + private bool _completed; + + public BoundedHashingWriteStream(Stream destination, long expectedBytes) + { + _destination = destination ?? throw new ArgumentNullException(nameof(destination)); + if (!destination.CanWrite) throw new ArgumentException("Content destination must be writable.", nameof(destination)); + if (expectedBytes < 0) throw new ArgumentOutOfRangeException(nameof(expectedBytes)); + _expectedBytes = expectedBytes; + } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => _written; + set => throw new NotSupportedException(); + } + + public override void Flush() => _destination.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => + _destination.FlushAsync(cancellationToken); + + public override void Write(byte[] buffer, int offset, int count) + { + ValidateWrite(buffer, offset, count); + _destination.Write(buffer, offset, count); + _sha.TransformBlock(buffer, offset, count, null, 0); + _written += count; + } + + public override async Task WriteAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + { + ValidateWrite(buffer, offset, count); + await _destination.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); + _sha.TransformBlock(buffer, offset, count, null, 0); + _written += count; + } + + public string CompleteHash() + { + if (_completed) throw new InvalidOperationException("Content destination hash is already complete."); + if (_written != _expectedBytes) + throw new EndOfStreamException( + $"Content transport wrote {_written} bytes; expected {_expectedBytes} bytes."); + _sha.TransformFinalBlock(Array.Empty(), 0, 0); + _completed = true; + return string.Concat(_sha.Hash!.Select(value => value.ToString("x2", CultureInfo.InvariantCulture))); + } + + protected override void Dispose(bool disposing) + { + if (disposing) _sha.Dispose(); + base.Dispose(disposing); + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + private void ValidateWrite(byte[] buffer, int offset, int count) + { + if (_completed) throw new InvalidOperationException("Content destination is already complete."); + if (buffer == null) throw new ArgumentNullException(nameof(buffer)); + if (offset < 0 || count < 0 || offset > buffer.Length - count) + throw new ArgumentOutOfRangeException(nameof(offset)); + if (_written + count > _expectedBytes) + throw new InvalidDataException( + $"Content transport exceeded its advertised {_expectedBytes}-byte chunk boundary."); + } + } + private async Task SaveStateAsync( string contentHash, string fingerprint, diff --git a/src/GameCult.Mesh/CultMeshContentTransports.cs b/src/GameCult.Mesh/CultMeshContentTransports.cs new file mode 100644 index 00000000..f1814b2c --- /dev/null +++ b/src/GameCult.Mesh/CultMeshContentTransports.cs @@ -0,0 +1,166 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace GameCult.Mesh +{ + /// + /// Moves immutable content bytes for one selected physical path. Content identity, + /// verification, checkpoints, and promotion remain owned by + /// . + /// + public interface ICultMeshContentTransport : IDisposable + { + string TransportId { get; } + string Endpoint { get; } + + /// + /// Writes exactly one advertised chunk into the caller-owned destination. + /// The transport must honor destination backpressure and must not close it. + /// + Task CopyChunkToAsync( + CultMeshCdnChunkRef chunk, + Stream destination, + CancellationToken cancellationToken = default); + } + + /// + /// Creates physical content transports. Lower priority values are attempted first; + /// a fallback tier is not raced against a preferred tier. + /// + public interface ICultMeshContentTransportConnector + { + string ConnectorId { get; } + int Priority { get; } + bool CanConnect(CultMeshTransportCandidate candidate); + Task ConnectAsync( + CultMeshTransportCandidate candidate, + CultMeshEndpointId endpointId, + CancellationToken cancellationToken = default); + } + + /// + /// Reusable identity-bound content session selected and owned by + /// . + /// + public sealed class CultMeshContentSession : IDisposable + { + private readonly object _gate = new(); + private readonly Action _onTransportFailure; + private ICultMeshContentTransport _transport; + private bool _disposed; + + internal CultMeshContentSession( + CultMeshEndpointId endpointId, + ICultMeshContentTransport transport, + CultMeshSessionState state, + Action onTransportFailure) + { + EndpointId = endpointId ?? throw new ArgumentNullException(nameof(endpointId)); + _transport = transport ?? throw new ArgumentNullException(nameof(transport)); + State = state ?? throw new ArgumentNullException(nameof(state)); + _onTransportFailure = onTransportFailure ?? throw new ArgumentNullException(nameof(onTransportFailure)); + } + + public CultMeshEndpointId EndpointId { get; } + public CultMeshSessionState State { get; private set; } + public string TransportId { get { lock (_gate) return _transport.TransportId; } } + + public async Task CopyChunkToAsync( + CultMeshCdnChunkRef chunk, + Stream destination, + CancellationToken cancellationToken = default) + { + if (chunk == null) throw new ArgumentNullException(nameof(chunk)); + if (destination == null) throw new ArgumentNullException(nameof(destination)); + ICultMeshContentTransport transport; + lock (_gate) + { + ThrowIfDisposed(); + transport = _transport; + } + try + { + await transport.CopyChunkToAsync(chunk, destination, cancellationToken).ConfigureAwait(false); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + _onTransportFailure(this); + throw; + } + } + + internal void Replace(ICultMeshContentTransport transport, CultMeshSessionState state) + { + if (transport == null) throw new ArgumentNullException(nameof(transport)); + ICultMeshContentTransport previous; + lock (_gate) + { + ThrowIfDisposed(); + previous = _transport; + _transport = transport; + State = state; + } + previous.Dispose(); + } + + internal void MarkOffline(CultMeshSessionState state) + { + lock (_gate) + { + if (_disposed) return; + State = state; + } + } + + public void Dispose() + { + ICultMeshContentTransport transport; + lock (_gate) + { + if (_disposed) return; + _disposed = true; + transport = _transport; + } + transport.Dispose(); + } + + private void ThrowIfDisposed() + { + if (_disposed) throw new ObjectDisposedException(nameof(CultMeshContentSession)); + } + } + + /// + /// Adapts a selected streaming transport to the verified content-transfer source port. + /// + public sealed class CultMeshSessionContentProvider : ICultMeshContentProvider + { + private readonly CultMeshSessionManager _sessions; + private readonly CultMeshEndpointId _endpointId; + + public CultMeshSessionContentProvider( + string providerId, + CultMeshSessionManager sessions, + CultMeshEndpointId endpointId) + { + ProviderId = string.IsNullOrWhiteSpace(providerId) + ? throw new ArgumentException("Provider identity is required.", nameof(providerId)) + : providerId; + _sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); + _endpointId = endpointId ?? throw new ArgumentNullException(nameof(endpointId)); + } + + public string ProviderId { get; } + + public async Task CopyChunkToAsync( + CultMeshCdnChunkRef chunk, + Stream destination, + CancellationToken cancellationToken = default) + { + var session = await _sessions.ConnectContentAsync(_endpointId, cancellationToken).ConfigureAwait(false); + await session.CopyChunkToAsync(chunk, destination, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/GameCult.Mesh/CultMeshRealtimeTransports.cs b/src/GameCult.Mesh/CultMeshRealtimeTransports.cs new file mode 100644 index 00000000..d087b304 --- /dev/null +++ b/src/GameCult.Mesh/CultMeshRealtimeTransports.cs @@ -0,0 +1,154 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GameCult.Mesh +{ + /// Delivery semantics requested from a realtime state transport. + public enum CultMeshRealtimeDelivery + { + ReliableOrdered, + LatestOnly, + Unreliable + } + + /// + /// Transport-neutral realtime state frame. Schema identity and logical generation + /// remain stable when the physical connector changes. + /// + public sealed class CultMeshRealtimeFrame + { + public string ChannelId { get; set; } = string.Empty; + public string SchemaId { get; set; } = string.Empty; + public string BodyId { get; set; } = string.Empty; + public long ProducerEpoch { get; set; } + public long Sequence { get; set; } + public CultMeshRealtimeDelivery Delivery { get; set; } + public ReadOnlyMemory Payload { get; set; } + + internal void Validate() + { + if (string.IsNullOrWhiteSpace(ChannelId)) + throw new InvalidOperationException("Realtime channel identity is required."); + if (string.IsNullOrWhiteSpace(SchemaId)) + throw new InvalidOperationException("Realtime schema identity is required."); + if (string.IsNullOrWhiteSpace(BodyId)) + throw new InvalidOperationException("Realtime body identity is required."); + if (ProducerEpoch < 0 || Sequence < 0) + throw new InvalidOperationException("Realtime epoch and sequence must be non-negative."); + } + } + + /// + /// Bidirectional realtime state plane. Commands, receipts, manifests, and other + /// registered schema documents do not travel through this port. + /// + public interface ICultMeshRealtimeTransport : IDisposable + { + string TransportId { get; } + string Endpoint { get; } + Task SendAsync(CultMeshRealtimeFrame frame, CancellationToken cancellationToken = default); + Task ReceiveAsync(CancellationToken cancellationToken = default); + } + + /// Creates a realtime state transport, normally QUIC streams/datagrams. + public interface ICultMeshRealtimeTransportConnector + { + string ConnectorId { get; } + int Priority { get; } + bool CanConnect(CultMeshTransportCandidate candidate); + Task ConnectAsync( + CultMeshTransportCandidate candidate, + CultMeshEndpointId endpointId, + CancellationToken cancellationToken = default); + } + + /// Reusable identity-bound realtime state session. + public sealed class CultMeshRealtimeSession : IDisposable + { + private readonly object _gate = new(); + private readonly Action _onTransportFailure; + private ICultMeshRealtimeTransport _transport; + private bool _disposed; + + internal CultMeshRealtimeSession( + CultMeshEndpointId endpointId, + ICultMeshRealtimeTransport transport, + CultMeshSessionState state, + Action onTransportFailure) + { + EndpointId = endpointId ?? throw new ArgumentNullException(nameof(endpointId)); + _transport = transport ?? throw new ArgumentNullException(nameof(transport)); + State = state ?? throw new ArgumentNullException(nameof(state)); + _onTransportFailure = onTransportFailure ?? throw new ArgumentNullException(nameof(onTransportFailure)); + } + + public CultMeshEndpointId EndpointId { get; } + public CultMeshSessionState State { get; private set; } + public string TransportId { get { lock (_gate) return _transport.TransportId; } } + + public async Task SendAsync( + CultMeshRealtimeFrame frame, + CancellationToken cancellationToken = default) + { + if (frame == null) throw new ArgumentNullException(nameof(frame)); + frame.Validate(); + var transport = GetTransport(); + try + { + await transport.SendAsync(frame, cancellationToken).ConfigureAwait(false); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + _onTransportFailure(this); + throw; + } + } + + public async Task ReceiveAsync(CancellationToken cancellationToken = default) + { + var transport = GetTransport(); + try + { + var frame = await transport.ReceiveAsync(cancellationToken).ConfigureAwait(false); + frame.Validate(); + return frame; + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + _onTransportFailure(this); + throw; + } + } + + internal void MarkOffline(CultMeshSessionState state) + { + lock (_gate) + { + if (_disposed) return; + State = state; + } + } + + public void Dispose() + { + ICultMeshRealtimeTransport transport; + lock (_gate) + { + if (_disposed) return; + _disposed = true; + transport = _transport; + } + transport.Dispose(); + } + + private ICultMeshRealtimeTransport GetTransport() + { + lock (_gate) + { + if (_disposed) throw new ObjectDisposedException(nameof(CultMeshRealtimeSession)); + return _transport; + } + } + } +} diff --git a/src/GameCult.Mesh/CultMeshSessions.cs b/src/GameCult.Mesh/CultMeshSessions.cs index 6c10e263..87f18158 100644 --- a/src/GameCult.Mesh/CultMeshSessions.cs +++ b/src/GameCult.Mesh/CultMeshSessions.cs @@ -39,6 +39,7 @@ namespace GameCult.Mesh public static CultMeshProtocolId Discovery { get; } = new CultMeshProtocolId("cultmesh.discovery.v1"); public static CultMeshProtocolId PeerExchange { get; } = new CultMeshProtocolId("cultmesh.peer_exchange.v1"); public static CultMeshProtocolId Subscriptions { get; } = new CultMeshProtocolId("cultmesh.subscriptions.v1"); + public static CultMeshProtocolId RealtimeState { get; } = new CultMeshProtocolId("cultmesh.realtime_state.v1"); } public enum CultMeshTransportPathKind { Direct, Relay, Tunnel } @@ -101,6 +102,7 @@ namespace GameCult.Mesh public interface ICultMeshTransportConnector { string ConnectorId { get; } + int Priority { get; } bool CanConnect(CultMeshTransportCandidate candidate); Task ConnectAsync( CultMeshTransportCandidate candidate, @@ -108,6 +110,10 @@ namespace GameCult.Mesh CancellationToken cancellationToken = default); } + /// + /// Explicit compatibility connector for the previous RUDP/LiteNetLib schema lanes. + /// Prefer for registered schemas. + /// public sealed class CultMeshSchemaTransportConnector : ICultMeshTransportConnector { private readonly Func _createClient; @@ -124,7 +130,8 @@ namespace GameCult.Mesh _connectTimeout = connectTimeout ?? TimeSpan.FromSeconds(5); } - public string ConnectorId => "cultnet-schema"; + public string ConnectorId => "legacy-datagram-schema"; + public int Priority => 10_000; public bool CanConnect(CultMeshTransportCandidate candidate) => Uri.TryCreate(candidate.Endpoint, UriKind.Absolute, out var uri) && (string.Equals(uri.Scheme, "rudp", StringComparison.OrdinalIgnoreCase) || @@ -159,6 +166,59 @@ namespace GameCult.Mesh } } + /// Preferred connector for registered schema, command, receipt, and manifest traffic. + public sealed class CultMeshTcpSchemaTransportConnector : ICultMeshTransportConnector + { + private readonly Func _createClient; + private readonly ICultMeshClock _clock; + private readonly TimeSpan _connectTimeout; + + public CultMeshTcpSchemaTransportConnector( + Func? createClient = null, + ICultMeshClock? clock = null, + TimeSpan? connectTimeout = null) + { + _createClient = createClient ?? (_ => CultNetSchemaClients.CreateTcpFramed()); + _clock = clock ?? CultMeshSystemClock.Instance; + _connectTimeout = connectTimeout ?? TimeSpan.FromSeconds(5); + } + + public string ConnectorId => "tcp-schema"; + public int Priority => 0; + public bool CanConnect(CultMeshTransportCandidate candidate) => + Uri.TryCreate(candidate.Endpoint, UriKind.Absolute, out var uri) && + string.Equals(uri.Scheme, "cultnet+tcp", StringComparison.OrdinalIgnoreCase); + + public async Task ConnectAsync( + CultMeshTransportCandidate candidate, + CultMeshProtocolId protocol, + CancellationToken cancellationToken = default) + { + if (!CanConnect(candidate)) + throw new NotSupportedException($"TCP schema connector does not support '{candidate.Endpoint}'."); + var client = _createClient(candidate.Endpoint); + try + { + var (host, port) = CultNetSchemaWriteForwarder.ParseEndpoint(candidate.Endpoint); + client.Connect(host, port); + var deadline = _clock.UtcNow + _connectTimeout; + while (!client.Connected) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_clock.UtcNow >= deadline) + throw new TimeoutException($"Timed out connecting to '{candidate.Endpoint}'."); + await _clock.DelayAsync(TimeSpan.FromMilliseconds(25), cancellationToken).ConfigureAwait(false); + } + return client; + } + catch + { + client.Dispose(); + throw; + } + } + } + public sealed class CultMeshSession : IDisposable { private readonly Subject _states = new(); @@ -328,8 +388,12 @@ namespace GameCult.Mesh { private readonly CultMeshDiscoveryService _discovery; private readonly ICultMeshTransportConnector[] _connectors; + private readonly ICultMeshContentTransportConnector[] _contentConnectors; + private readonly ICultMeshRealtimeTransportConnector[] _realtimeConnectors; private readonly CultMeshSessionManagerOptions _options; private readonly ConcurrentDictionary>> _sessions = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary>> _contentSessions = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary>> _realtimeSessions = new(StringComparer.Ordinal); private long _diagnosticSequence; private bool _disposed; @@ -337,9 +401,44 @@ namespace GameCult.Mesh CultMeshDiscoveryService discovery, IEnumerable connectors, CultMeshSessionManagerOptions? options = null) + : this( + discovery, + connectors, + Array.Empty(), + Array.Empty(), + options) + { + } + + public CultMeshSessionManager( + CultMeshDiscoveryService discovery, + IEnumerable connectors, + IEnumerable contentConnectors, + CultMeshSessionManagerOptions? options = null) + : this( + discovery, + connectors, + contentConnectors, + Array.Empty(), + options) + { + } + + public CultMeshSessionManager( + CultMeshDiscoveryService discovery, + IEnumerable connectors, + IEnumerable contentConnectors, + IEnumerable realtimeConnectors, + CultMeshSessionManagerOptions? options = null) { _discovery = discovery ?? throw new ArgumentNullException(nameof(discovery)); _connectors = connectors?.ToArray() ?? throw new ArgumentNullException(nameof(connectors)); + _contentConnectors = contentConnectors?.ToArray() ?? throw new ArgumentNullException(nameof(contentConnectors)); + _realtimeConnectors = realtimeConnectors?.ToArray() ?? throw new ArgumentNullException(nameof(realtimeConnectors)); + if (_contentConnectors.Any(connector => connector == null)) + throw new ArgumentException("Content transport connectors cannot contain null entries.", nameof(contentConnectors)); + if (_realtimeConnectors.Any(connector => connector == null)) + throw new ArgumentException("Realtime transport connectors cannot contain null entries.", nameof(realtimeConnectors)); _options = options ?? new CultMeshSessionManagerOptions(); if (_options.MaxRacedCandidates <= 0) throw new ArgumentOutOfRangeException(nameof(options)); } @@ -371,6 +470,257 @@ namespace GameCult.Mesh } } + /// + /// Connects one reusable streaming content plane by stable endpoint identity. + /// Preferred connector tiers are exhausted before legacy fallback tiers are attempted. + /// + public async Task ConnectContentAsync( + CultMeshEndpointId endpointId, + CancellationToken cancellationToken = default) + { + if (endpointId == null) throw new ArgumentNullException(nameof(endpointId)); + ThrowIfDisposed(); + var key = endpointId.Value; + while (true) + { + var lazy = _contentSessions.GetOrAdd(key, _ => new Lazy>( + () => ConnectContentOwnedAsync(key, endpointId), LazyThreadSafetyMode.ExecutionAndPublication)); + try + { + var session = await AwaitForCallerAsync(lazy.Value, cancellationToken).ConfigureAwait(false); + if (session.State.Status != CultMeshSessionStatus.Offline) return session; + _contentSessions.TryRemove(key, out _); + } + catch + { + _contentSessions.TryRemove(key, out _); + throw; + } + } + } + + private async Task ConnectContentOwnedAsync( + string key, + CultMeshEndpointId endpointId) + { + var result = await ResolveContentPathAsync(endpointId).ConfigureAwait(false); + var session = new CultMeshContentSession( + endpointId, + result.Transport, + new CultMeshSessionState(CultMeshSessionStatus.Online, _options.Clock.UtcNow, result.Candidate), + failed => InvalidateContentSession(key, failed)); + Emit(endpointId, CultMeshProtocols.Content, "online", result.Candidate.Endpoint); + return session; + } + + private async Task ResolveContentPathAsync(CultMeshEndpointId endpointId) + { + if (_contentConnectors.Length == 0) + throw Failure( + CultMeshSessionFailureReason.UnsupportedPath, + "No streaming content connectors are configured. Register the TCP content connector or the explicit legacy RUDP connector."); + + var discovery = await _discovery.ResolveAsync(new CultMeshDiscoveryQuery(endpointId.Value)).ConfigureAwait(false); + var candidates = discovery.Candidates + .SelectMany(candidate => candidate.Descriptor.DiscoveryEndpoints) + .Distinct(StringComparer.Ordinal) + .Select(endpoint => new CultMeshTransportCandidate(endpoint)) + .ToArray(); + var routes = candidates + .SelectMany(candidate => _contentConnectors + .Where(connector => connector.CanConnect(candidate)) + .Select(connector => new ContentRoute(candidate, connector))) + .OrderBy(route => route.Connector.Priority) + .ThenBy(route => route.Candidate.Priority) + .ToArray(); + if (routes.Length == 0) + throw Failure( + CultMeshSessionFailureReason.UnsupportedPath, + $"No streaming content connector supports an advertised route for '{endpointId}'."); + + var failures = new List(); + foreach (var tier in routes.GroupBy(route => route.Connector.Priority).OrderBy(group => group.Key)) + { + var attempts = tier.Take(_options.MaxRacedCandidates) + .Select(route => ConnectContentCandidateAsync(endpointId, route)) + .ToList(); + while (attempts.Count > 0) + { + var completed = await Task.WhenAny(attempts).ConfigureAwait(false); + attempts.Remove(completed); + try + { + var result = await completed.ConfigureAwait(false); + foreach (var loser in attempts) + _ = loser.ContinueWith(task => + { + if (task.Status == TaskStatus.RanToCompletion) task.Result.Transport.Dispose(); + }, TaskScheduler.Default); + return result; + } + catch (Exception error) + { + failures.Add(error); + } + } + } + + var last = failures.LastOrDefault(); + throw Failure( + last is TimeoutException ? CultMeshSessionFailureReason.Timeout : CultMeshSessionFailureReason.Transport, + $"No streaming content path connected for '{endpointId}'.", + last); + } + + private static async Task ConnectContentCandidateAsync( + CultMeshEndpointId endpointId, + ContentRoute route) + { + var transport = await route.Connector.ConnectAsync(route.Candidate, endpointId).ConfigureAwait(false); + return new ConnectedContentPath(route.Candidate, transport); + } + + private void InvalidateContentSession(string key, CultMeshContentSession session) + { + session.MarkOffline(new CultMeshSessionState( + CultMeshSessionStatus.Offline, + _options.Clock.UtcNow, + session.State.Path, + new CultMeshSessionFailure( + CultMeshSessionFailureReason.Transport, + "The selected streaming content transport failed.", + session.State.Path?.Endpoint ?? string.Empty))); + if (_contentSessions.TryRemove(key, out var removed) && + removed.IsValueCreated && removed.Value.Status == TaskStatus.RanToCompletion) + removed.Value.Result.Dispose(); + Emit(session.EndpointId, CultMeshProtocols.Content, "offline", session.State.Path?.Endpoint ?? string.Empty); + } + + /// + /// Connects the realtime state plane. No connector is implied: promoted runtimes + /// register QUIC explicitly, while RUDP compatibility remains opt-in. + /// + public async Task ConnectRealtimeAsync( + CultMeshEndpointId endpointId, + CancellationToken cancellationToken = default) + { + if (endpointId == null) throw new ArgumentNullException(nameof(endpointId)); + ThrowIfDisposed(); + var key = endpointId.Value; + while (true) + { + var lazy = _realtimeSessions.GetOrAdd(key, _ => new Lazy>( + () => ConnectRealtimeOwnedAsync(key, endpointId), LazyThreadSafetyMode.ExecutionAndPublication)); + try + { + var session = await AwaitForCallerAsync(lazy.Value, cancellationToken).ConfigureAwait(false); + if (session.State.Status != CultMeshSessionStatus.Offline) return session; + _realtimeSessions.TryRemove(key, out _); + } + catch + { + _realtimeSessions.TryRemove(key, out _); + throw; + } + } + } + + private async Task ConnectRealtimeOwnedAsync( + string key, + CultMeshEndpointId endpointId) + { + var result = await ResolveRealtimePathAsync(endpointId).ConfigureAwait(false); + var session = new CultMeshRealtimeSession( + endpointId, + result.Transport, + new CultMeshSessionState(CultMeshSessionStatus.Online, _options.Clock.UtcNow, result.Candidate), + failed => InvalidateRealtimeSession(key, failed)); + Emit(endpointId, CultMeshProtocols.RealtimeState, "online", result.Candidate.Endpoint); + return session; + } + + private async Task ResolveRealtimePathAsync(CultMeshEndpointId endpointId) + { + if (_realtimeConnectors.Length == 0) + throw Failure( + CultMeshSessionFailureReason.UnsupportedPath, + "No realtime state connectors are configured. Register a QUIC connector explicitly."); + + var discovery = await _discovery.ResolveAsync(new CultMeshDiscoveryQuery(endpointId.Value)).ConfigureAwait(false); + var routes = discovery.Candidates + .SelectMany(candidate => candidate.Descriptor.DiscoveryEndpoints) + .Distinct(StringComparer.Ordinal) + .Select(endpoint => new CultMeshTransportCandidate(endpoint)) + .SelectMany(candidate => _realtimeConnectors + .Where(connector => connector.CanConnect(candidate)) + .Select(connector => new RealtimeRoute(candidate, connector))) + .OrderBy(route => route.Connector.Priority) + .ThenBy(route => route.Candidate.Priority) + .ToArray(); + if (routes.Length == 0) + throw Failure( + CultMeshSessionFailureReason.UnsupportedPath, + $"No realtime connector supports an advertised route for '{endpointId}'."); + + var failures = new List(); + foreach (var tier in routes.GroupBy(route => route.Connector.Priority).OrderBy(group => group.Key)) + { + var attempts = tier.Take(_options.MaxRacedCandidates) + .Select(route => ConnectRealtimeCandidateAsync(endpointId, route)) + .ToList(); + while (attempts.Count > 0) + { + var completed = await Task.WhenAny(attempts).ConfigureAwait(false); + attempts.Remove(completed); + try + { + var result = await completed.ConfigureAwait(false); + foreach (var loser in attempts) + _ = loser.ContinueWith(task => + { + if (task.Status == TaskStatus.RanToCompletion) task.Result.Transport.Dispose(); + }, TaskScheduler.Default); + return result; + } + catch (Exception error) + { + failures.Add(error); + } + } + } + + var last = failures.LastOrDefault(); + throw Failure( + last is TimeoutException ? CultMeshSessionFailureReason.Timeout : CultMeshSessionFailureReason.Transport, + $"No realtime state path connected for '{endpointId}'.", + last); + } + + private static async Task ConnectRealtimeCandidateAsync( + CultMeshEndpointId endpointId, + RealtimeRoute route) + { + var transport = await route.Connector.ConnectAsync(route.Candidate, endpointId).ConfigureAwait(false); + return new ConnectedRealtimePath(route.Candidate, transport); + } + + private void InvalidateRealtimeSession(string key, CultMeshRealtimeSession session) + { + var endpoint = session.State.Path?.Endpoint ?? string.Empty; + session.MarkOffline(new CultMeshSessionState( + CultMeshSessionStatus.Offline, + _options.Clock.UtcNow, + session.State.Path, + new CultMeshSessionFailure( + CultMeshSessionFailureReason.Transport, + "The selected realtime state transport failed.", + endpoint))); + if (_realtimeSessions.TryRemove(key, out var removed) && + removed.IsValueCreated && removed.Value.Status == TaskStatus.RanToCompletion) + removed.Value.Result.Dispose(); + Emit(session.EndpointId, CultMeshProtocols.RealtimeState, "offline", endpoint); + } + private async Task ConnectOwnedAsync(string key, CultMeshEndpointId endpointId, CultMeshProtocolId protocol) { var result = await ResolveConnectedPathAsync(endpointId, protocol).ConfigureAwait(false); @@ -387,26 +737,43 @@ namespace GameCult.Mesh var candidates = discovery.Candidates.SelectMany(candidate => candidate.Descriptor.DiscoveryEndpoints) .Distinct(StringComparer.Ordinal) .Select(endpoint => new CultMeshTransportCandidate(endpoint)) - .OrderBy(candidate => candidate.Priority) - .Take(_options.MaxRacedCandidates) + .ToArray(); + var routes = candidates + .SelectMany(candidate => _connectors + .Where(connector => connector.CanConnect(candidate)) + .Select(connector => new SchemaRoute(candidate, connector))) + .OrderBy(route => route.Connector.Priority) + .ThenBy(route => route.Candidate.Priority) .ToArray(); if (candidates.Length == 0) throw Failure(CultMeshSessionFailureReason.Resolution, $"No route candidates for '{endpointId}'."); + if (routes.Length == 0) + throw Failure( + CultMeshSessionFailureReason.Transport, + $"No transport path connected for '{endpointId}' and protocol '{protocol}'.", + Failure( + CultMeshSessionFailureReason.UnsupportedPath, + $"No registered connector supports an advertised route for '{endpointId}'.")); - var attempts = candidates.Select(candidate => ConnectCandidateAsync(candidate, protocol)).ToList(); var failures = new List(); - while (attempts.Count > 0) + foreach (var tier in routes.GroupBy(route => route.Connector.Priority).OrderBy(group => group.Key)) { - var completed = await Task.WhenAny(attempts).ConfigureAwait(false); - attempts.Remove(completed); - try + var attempts = tier.Take(_options.MaxRacedCandidates) + .Select(route => ConnectCandidateAsync(route, protocol)) + .ToList(); + while (attempts.Count > 0) { - var result = await completed.ConfigureAwait(false); - foreach (var loser in attempts) - _ = loser.ContinueWith(task => { if (task.Status == TaskStatus.RanToCompletion) task.Result.Client.Dispose(); }, TaskScheduler.Default); - return result; + var completed = await Task.WhenAny(attempts).ConfigureAwait(false); + attempts.Remove(completed); + try + { + var result = await completed.ConfigureAwait(false); + foreach (var loser in attempts) + _ = loser.ContinueWith(task => { if (task.Status == TaskStatus.RanToCompletion) task.Result.Client.Dispose(); }, TaskScheduler.Default); + return result; + } + catch (Exception error) { failures.Add(error); } } - catch (Exception error) { failures.Add(error); } } var last = failures.LastOrDefault(); throw Failure(last is TimeoutException ? CultMeshSessionFailureReason.Timeout : CultMeshSessionFailureReason.Transport, @@ -467,12 +834,10 @@ namespace GameCult.Mesh } } - private async Task ConnectCandidateAsync(CultMeshTransportCandidate candidate, CultMeshProtocolId protocol) + private static async Task ConnectCandidateAsync(SchemaRoute route, CultMeshProtocolId protocol) { - var connector = _connectors.FirstOrDefault(item => item.CanConnect(candidate)); - if (connector == null) throw Failure(CultMeshSessionFailureReason.UnsupportedPath, $"No connector supports '{candidate.Endpoint}'."); - var client = await connector.ConnectAsync(candidate, protocol).ConfigureAwait(false); - return new ConnectedPath(candidate, client); + var client = await route.Connector.ConnectAsync(route.Candidate, protocol).ConfigureAwait(false); + return new ConnectedPath(route.Candidate, client); } private static async Task AwaitForCallerAsync(Task shared, CancellationToken cancellationToken) @@ -506,6 +871,12 @@ namespace GameCult.Mesh foreach (var lazy in _sessions.Values) if (lazy.IsValueCreated && lazy.Value.Status == TaskStatus.RanToCompletion) lazy.Value.Result.Dispose(); _sessions.Clear(); + foreach (var lazy in _contentSessions.Values) + if (lazy.IsValueCreated && lazy.Value.Status == TaskStatus.RanToCompletion) lazy.Value.Result.Dispose(); + _contentSessions.Clear(); + foreach (var lazy in _realtimeSessions.Values) + if (lazy.IsValueCreated && lazy.Value.Status == TaskStatus.RanToCompletion) lazy.Value.Result.Dispose(); + _realtimeSessions.Clear(); } private void ThrowIfDisposed() { if (_disposed) throw new ObjectDisposedException(nameof(CultMeshSessionManager)); } @@ -516,5 +887,60 @@ namespace GameCult.Mesh public CultMeshTransportCandidate Candidate { get; } public ICultNetSchemaClient Client { get; } } + + private sealed class ConnectedContentPath + { + public ConnectedContentPath(CultMeshTransportCandidate candidate, ICultMeshContentTransport transport) + { + Candidate = candidate; + Transport = transport; + } + public CultMeshTransportCandidate Candidate { get; } + public ICultMeshContentTransport Transport { get; } + } + + private sealed class SchemaRoute + { + public SchemaRoute(CultMeshTransportCandidate candidate, ICultMeshTransportConnector connector) + { + Candidate = candidate; + Connector = connector; + } + public CultMeshTransportCandidate Candidate { get; } + public ICultMeshTransportConnector Connector { get; } + } + + private sealed class ContentRoute + { + public ContentRoute(CultMeshTransportCandidate candidate, ICultMeshContentTransportConnector connector) + { + Candidate = candidate; + Connector = connector; + } + public CultMeshTransportCandidate Candidate { get; } + public ICultMeshContentTransportConnector Connector { get; } + } + + private sealed class ConnectedRealtimePath + { + public ConnectedRealtimePath(CultMeshTransportCandidate candidate, ICultMeshRealtimeTransport transport) + { + Candidate = candidate; + Transport = transport; + } + public CultMeshTransportCandidate Candidate { get; } + public ICultMeshRealtimeTransport Transport { get; } + } + + private sealed class RealtimeRoute + { + public RealtimeRoute(CultMeshTransportCandidate candidate, ICultMeshRealtimeTransportConnector connector) + { + Candidate = candidate; + Connector = connector; + } + public CultMeshTransportCandidate Candidate { get; } + public ICultMeshRealtimeTransportConnector Connector { get; } + } } } diff --git a/src/GameCult.Mesh/CultMeshTcpContentTransport.cs b/src/GameCult.Mesh/CultMeshTcpContentTransport.cs new file mode 100644 index 00000000..f70f2a34 --- /dev/null +++ b/src/GameCult.Mesh/CultMeshTcpContentTransport.cs @@ -0,0 +1,421 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Networking; + +namespace GameCult.Mesh +{ + /// Creates dedicated TCP streams for immutable CultMesh content. + public sealed class CultMeshTcpContentTransportConnector : ICultMeshContentTransportConnector + { + public const string Scheme = "cultmesh-content+tcp"; + private readonly TimeSpan _connectTimeout; + private int _connectCount; + + public CultMeshTcpContentTransportConnector(TimeSpan? connectTimeout = null) + { + _connectTimeout = connectTimeout ?? TimeSpan.FromSeconds(5); + if (_connectTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(connectTimeout)); + } + + public string ConnectorId => "tcp-content"; + public int Priority => 0; + public int ConnectCount => Volatile.Read(ref _connectCount); + + public bool CanConnect(CultMeshTransportCandidate candidate) => + TryParseEndpoint(candidate?.Endpoint, out _, out _); + + public async Task ConnectAsync( + CultMeshTransportCandidate candidate, + CultMeshEndpointId endpointId, + CancellationToken cancellationToken = default) + { + if (candidate == null) throw new ArgumentNullException(nameof(candidate)); + if (endpointId == null) throw new ArgumentNullException(nameof(endpointId)); + if (!TryParseEndpoint(candidate.Endpoint, out var host, out var port)) + throw new NotSupportedException($"TCP content connector does not support '{candidate.Endpoint}'."); + + var client = new TcpClient(); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(_connectTimeout); + try + { + await CultMeshTcpContentWire.AwaitAsync(client.ConnectAsync(host, port), timeout.Token) + .ConfigureAwait(false); + Interlocked.Increment(ref _connectCount); + return new CultMeshTcpContentTransport(candidate.Endpoint, client); + } + catch + { + client.Dispose(); + throw; + } + } + + internal static bool TryParseEndpoint(string? endpoint, out string host, out int port) + { + host = string.Empty; + port = 0; + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) || + !string.Equals(uri.Scheme, Scheme, StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(uri.Host) || uri.Port <= 0) + return false; + host = uri.Host; + port = uri.Port; + return true; + } + } + + internal sealed class CultMeshTcpContentTransport : ICultMeshContentTransport + { + private readonly TcpClient _client; + private readonly NetworkStream _stream; + private readonly SemaphoreSlim _sendLock = new(1, 1); + private readonly ConcurrentDictionary _pending = new(StringComparer.Ordinal); + private readonly CancellationTokenSource _shutdown = new(); + private readonly Task _receiveLoop; + private bool _disposed; + + public CultMeshTcpContentTransport(string endpoint, TcpClient client) + { + Endpoint = endpoint; + _client = client ?? throw new ArgumentNullException(nameof(client)); + _stream = client.GetStream(); + _receiveLoop = ReceiveLoopAsync(_shutdown.Token); + } + + public string TransportId => "tcp-content"; + public string Endpoint { get; } + + public async Task CopyChunkToAsync( + CultMeshCdnChunkRef chunk, + Stream destination, + CancellationToken cancellationToken = default) + { + if (_disposed) throw new ObjectDisposedException(nameof(CultMeshTcpContentTransport)); + if (chunk == null) throw new ArgumentNullException(nameof(chunk)); + if (destination == null) throw new ArgumentNullException(nameof(destination)); + if (!destination.CanWrite) throw new ArgumentException("Content destination must be writable.", nameof(destination)); + + var messageId = Guid.NewGuid().ToString("N"); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var pending = new PendingCopy(destination, completion, cancellationToken); + if (!_pending.TryAdd(messageId, pending)) + throw new InvalidOperationException("Duplicate TCP content request identity."); + try + { + var request = new CultMeshContentChunkRequestMessage + { + MessageId = messageId, + ChunkHash = CultMeshCdn.NormalizeHash(chunk.ChunkHash, nameof(chunk.ChunkHash)), + RecordKey = string.IsNullOrWhiteSpace(chunk.RecordKey) + ? CultMeshCdnArtifactChunk.CreateRecordKey(chunk.ChunkHash).Value + : chunk.RecordKey, + ExpectedSizeBytes = chunk.SizeBytes + }; + var payload = CultNetSchemaMessageSerialization.Serialize(request); + await _sendLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await CultMeshTcpContentWire.WriteFrameAsync(_stream, payload, cancellationToken).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } + await CultMeshTcpContentWire.AwaitAsync(completion.Task, cancellationToken).ConfigureAwait(false); + } + finally + { + _pending.TryRemove(messageId, out _); + pending.Dispose(); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _shutdown.Cancel(); + _client.Dispose(); + var error = new ObjectDisposedException(nameof(CultMeshTcpContentTransport)); + foreach (var pending in _pending.Values) pending.Completion.TrySetException(error); + _pending.Clear(); + _sendLock.Dispose(); + _shutdown.Dispose(); + } + + private async Task ReceiveLoopAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + var headerBytes = await CultMeshTcpContentWire.ReadFrameAsync(_stream, cancellationToken).ConfigureAwait(false); + var response = CultNetSchemaMessageSerialization.Deserialize(headerBytes) as CultMeshContentChunkResponseMessage + ?? throw new InvalidDataException("TCP content response used an unexpected schema."); + if (!_pending.TryGetValue(response.MessageId ?? string.Empty, out var pending)) + throw new InvalidDataException("TCP content response did not match an active request."); + if (!response.Found) + { + pending.Completion.TrySetException(new FileNotFoundException(response.Error, response.ChunkHash)); + continue; + } + await CultMeshTcpContentWire.CopyExactlyAsync( + _stream, + pending.Destination, + response.SizeBytes, + pending.CancellationToken).ConfigureAwait(false); + pending.Completion.TrySetResult(true); + } + } + catch (Exception error) when (!_disposed) + { + foreach (var pending in _pending.Values) pending.Completion.TrySetException(error); + } + } + + private sealed class PendingCopy : IDisposable + { + private readonly CancellationTokenRegistration _registration; + + public PendingCopy( + Stream destination, + TaskCompletionSource completion, + CancellationToken cancellationToken) + { + Destination = destination; + Completion = completion; + CancellationToken = cancellationToken; + _registration = cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken)); + } + + public Stream Destination { get; } + public TaskCompletionSource Completion { get; } + public CancellationToken CancellationToken { get; } + public void Dispose() => _registration.Dispose(); + } + } + + /// + /// Serves content-addressed chunks over a dedicated TCP byte stream. Typed headers + /// remain CultNet schema messages; chunk bytes are streamed outside those messages. + /// + public sealed class CultMeshTcpContentServer : IDisposable + { + private readonly TcpListener _listener; + private readonly CultCache _content; + private readonly Func _canServeHash; + private readonly CancellationTokenSource _shutdown = new(); + private readonly ConcurrentDictionary _clients = new(); + private readonly Task _acceptLoop; + private long _chunkRequestsServed; + private bool _disposed; + + public CultMeshTcpContentServer( + TcpListener listener, + CultCache content, + Func? canServeHash = null) + { + _listener = listener ?? throw new ArgumentNullException(nameof(listener)); + _content = content ?? throw new ArgumentNullException(nameof(content)); + _canServeHash = canServeHash ?? (_ => true); + _listener.Start(); + _acceptLoop = AcceptLoopAsync(_shutdown.Token); + } + + public IPEndPoint LocalEndPoint => (IPEndPoint)_listener.LocalEndpoint; + public long ChunkRequestsServed => Interlocked.Read(ref _chunkRequestsServed); + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _shutdown.Cancel(); + _listener.Stop(); + foreach (var client in _clients.Keys) client.Dispose(); + _clients.Clear(); + _shutdown.Dispose(); + } + + private async Task AcceptLoopAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + var client = await _listener.AcceptTcpClientAsync().ConfigureAwait(false); + _clients.TryAdd(client, 0); + _ = ServeClientAsync(client, cancellationToken); + } + } + catch (Exception) when (_disposed || cancellationToken.IsCancellationRequested) + { + } + } + + private async Task ServeClientAsync(TcpClient client, CancellationToken cancellationToken) + { + try + { + using var stream = client.GetStream(); + while (!cancellationToken.IsCancellationRequested) + { + var requestBytes = await CultMeshTcpContentWire.ReadFrameAsync(stream, cancellationToken).ConfigureAwait(false); + var request = CultNetSchemaMessageSerialization.Deserialize(requestBytes) as CultMeshContentChunkRequestMessage + ?? throw new InvalidDataException("TCP content request used an unexpected schema."); + await SendChunkAsync(stream, request, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception) when (_disposed || cancellationToken.IsCancellationRequested || !client.Connected) + { + } + finally + { + _clients.TryRemove(client, out _); + client.Dispose(); + } + } + + private async Task SendChunkAsync( + Stream stream, + CultMeshContentChunkRequestMessage request, + CancellationToken cancellationToken) + { + var response = new CultMeshContentChunkResponseMessage + { + MessageId = request.MessageId, + ChunkHash = request.ChunkHash, + Payload = Array.Empty() + }; + CultMeshCdnArtifactChunk? chunk = null; + try + { + if (string.IsNullOrWhiteSpace(request.MessageId)) + throw new InvalidDataException("Content chunk request requires a message identity."); + var hash = CultMeshCdn.NormalizeHash(request.ChunkHash, nameof(request.ChunkHash)); + if (!_canServeHash(hash)) + throw new UnauthorizedAccessException("Content access policy rejected the requested hash."); + var key = CultMeshCdnArtifactChunk.CreateRecordKey(hash); + if (!string.IsNullOrWhiteSpace(request.RecordKey) && + !string.Equals(request.RecordKey, key.Value, StringComparison.Ordinal)) + throw new InvalidDataException("Content chunk record key disagrees with its content hash."); + chunk = _content.Get(key) + ?? throw new FileNotFoundException("Content chunk is not available.", key.Value); + CultMeshCdn.ValidateChunkPayload(new CultMeshCdnChunkRef + { + ChunkHash = hash, + RecordKey = key.Value, + SizeBytes = request.ExpectedSizeBytes + }, chunk); + response.Found = true; + response.ChunkHash = hash; + response.SizeBytes = chunk.SizeBytes; + Interlocked.Increment(ref _chunkRequestsServed); + } + catch (Exception error) + { + response.Found = false; + response.Error = error.GetType().Name + ": " + error.Message; + } + + await CultMeshTcpContentWire.WriteFrameAsync( + stream, + CultNetSchemaMessageSerialization.Serialize(response), + cancellationToken).ConfigureAwait(false); + if (chunk != null) + { + await stream.WriteAsync(chunk.Payload, 0, chunk.Payload.Length, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + } + + internal static class CultMeshTcpContentWire + { + private const int HeaderBytes = 4; + private const int MaximumHeaderBytes = 1024 * 1024; + + public static async Task AwaitAsync(Task task, CancellationToken cancellationToken) + { + if (!cancellationToken.CanBeCanceled) + { + await task.ConfigureAwait(false); + return; + } + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using (cancellationToken.Register(() => cancelled.TrySetResult(true))) + { + if (await Task.WhenAny(task, cancelled.Task).ConfigureAwait(false) != task) + throw new OperationCanceledException(cancellationToken); + } + await task.ConfigureAwait(false); + } + + public static async Task WriteFrameAsync( + Stream stream, + byte[] payload, + CancellationToken cancellationToken) + { + if (payload.Length > MaximumHeaderBytes) + throw new InvalidDataException("TCP content control frame exceeds its size bound."); + var header = new byte[HeaderBytes]; + BinaryPrimitives.WriteUInt32BigEndian(header, checked((uint)payload.Length)); + await stream.WriteAsync(header, 0, header.Length, cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(payload, 0, payload.Length, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + public static async Task ReadFrameAsync(Stream stream, CancellationToken cancellationToken) + { + var header = new byte[HeaderBytes]; + await ReadExactlyAsync(stream, header, cancellationToken).ConfigureAwait(false); + var size = checked((int)BinaryPrimitives.ReadUInt32BigEndian(header)); + if (size <= 0 || size > MaximumHeaderBytes) + throw new InvalidDataException("TCP content control frame has an invalid size."); + var payload = new byte[size]; + await ReadExactlyAsync(stream, payload, cancellationToken).ConfigureAwait(false); + return payload; + } + + public static async Task CopyExactlyAsync( + Stream source, + Stream destination, + long bytes, + CancellationToken cancellationToken) + { + if (bytes < 0) throw new InvalidDataException("TCP content body size cannot be negative."); + var buffer = new byte[81920]; + var remaining = bytes; + while (remaining > 0) + { + var requested = (int)Math.Min(buffer.Length, remaining); + var read = await source.ReadAsync(buffer, 0, requested, cancellationToken).ConfigureAwait(false); + if (read == 0) throw new EndOfStreamException(); + await destination.WriteAsync(buffer, 0, read, cancellationToken).ConfigureAwait(false); + remaining -= read; + } + } + + private static async Task ReadExactlyAsync( + Stream stream, + byte[] buffer, + CancellationToken cancellationToken) + { + var offset = 0; + while (offset < buffer.Length) + { + var read = await stream.ReadAsync(buffer, offset, buffer.Length - offset, cancellationToken) + .ConfigureAwait(false); + if (read == 0) throw new EndOfStreamException(); + offset += read; + } + } + } +} diff --git a/src/GameCult.Mesh/docs/reliability-migration-plan.md b/src/GameCult.Mesh/docs/reliability-migration-plan.md index bcab4537..72e60ca9 100644 --- a/src/GameCult.Mesh/docs/reliability-migration-plan.md +++ b/src/GameCult.Mesh/docs/reliability-migration-plan.md @@ -2,6 +2,8 @@ Incident record: [CultMesh bulk content over custom RUDP, July 2026](postmortem-2026-07-cultmesh-bulk-rudp.md). +Physical-plane authority: [CultMesh transport planes](transport-planes.md). + ## Objective CultMesh should give an application one typed, watchable route to a Verse, @@ -676,16 +678,17 @@ body in process. **Owner:** CultMesh negotiation selects a body transport from provider-owned capabilities and consumer demand. The selected transport owns byte movement; `CultMeshContentTransferService` continues to own content identity, resumable -verification, and atomic cache promotion. CultNet RUDP does not own bulk-body -performance. +verification, and atomic cache promotion. TCP owns remote immutable content; +CultNet RUDP does not own bulk-body performance. **Inputs:** content hash and size, provider-advertised transport candidates, consumer-supported transports, same-machine evidence, and platform support. **Outputs:** an authorized mapped body for same-machine deployments, or a -dedicated remote byte stream feeding the existing verification/promotion owner. -Typed control state, reactive variables, commands, and receipts remain CultNet -messages and are not smuggled into the bulk stream. +dedicated TCP byte stream feeding the existing verification/promotion owner. +Schemas, commands, receipts, and manifests use framed TCP CultNet messages. +Realtime state uses a separately negotiated QUIC stream or datagram and is not +smuggled through either snapshots or the immutable-content stream. **Derived state:** selected plane, physical endpoint, mapped-file descriptor, stream progress, and transport telemetry. None can change content identity, diff --git a/src/GameCult.Mesh/docs/transport-planes.md b/src/GameCult.Mesh/docs/transport-planes.md new file mode 100644 index 00000000..879bd5c6 --- /dev/null +++ b/src/GameCult.Mesh/docs/transport-planes.md @@ -0,0 +1,74 @@ +# CultMesh Transport Planes + +CultMesh selects a transport per workload. It does not ask one wire protocol to +be equally good at files, realtime state, and same-machine memory access. + +## Authority map + +**Owner:** `CultMeshSessionManager` selects an advertised route through a +registered connector for each traffic class. A connector owns only its physical +connection and byte movement. + +**Inputs:** stable endpoint identity, advertised physical candidates, connector +support and priority, caller cancellation, and locality evidence for body +negotiation. + +**Outputs:** + +| Workload | Preferred plane | Contract | +| --- | --- | --- | +| Schemas, commands, receipts, manifests | TCP | `cultnet+tcp://` through `ICultMeshTransportConnector` | +| Immutable chunks and other file-shaped bodies | TCP byte stream | `cultmesh-content+tcp://` through `ICultMeshContentTransportConnector` | +| Realtime state | QUIC streams or datagrams | `ICultMeshRealtimeTransportConnector` with an explicit delivery mode | +| Same-machine SoA and large bodies | mapped/shared memory | body transport negotiation | + +The TCP content plane sends typed CultNet request and response headers, then +streams raw chunk bytes outside the schema envelope. The transfer service owns +range hashing, resume checkpoints, provider failover, full-body verification, +and atomic promotion regardless of the selected content connector. +`CultMeshClient` installs the TCP schema and TCP content connectors by default. +Passing explicit connector lists replaces those defaults. + +Realtime frames declare whether they require reliable ordered delivery, +latest-only delivery, or unreliable delivery. The contract is ready for a QUIC +connector; no RUDP implementation is installed implicitly in its place. + +**Derived state:** connector choice, physical endpoint, connection health, and +transport diagnostics. None of these grant provider authority or become content +identity. + +**Forbidden writers:** applications and renderers do not choose unadvertised +endpoints, implement reconnect loops, or bypass verification. Schema messages +must not carry bulk bodies. RUDP/LiteNetLib cannot become an implicit default +for any traffic class. QUIC does not own schemas or immutable files merely +because it can transport bytes. + +**Shared paths:** discovery and identity resolution remain common. Each traffic +class has its own connector set, connection cache, priority tiers, and failure +state. All content connectors feed the same transfer and promotion owner. + +**Cut line:** the former RUDP content server is +`CultMeshLegacyRudpContentServer`, and its connector must be registered +explicitly. It has basement priority and exists for compatibility and parity +measurement. The legacy datagram schema connector is likewise explicit and +lower priority than TCP. Existing hot-state publishers that still package +payloads as schema messages must move to the realtime connector contract; the +new seam does not pretend that migration has already occurred. + +## Security and release gate + +The current TCP connectors are a local-development transport and are plaintext. +Remote release requires authenticated encryption and binding the authenticated +peer to the advertised provider identity. Adding TLS must not move discovery, +authorization, content verification, or application semantics into the TCP +adapter. + +## Local parity evidence + +On 2026-07-19, the parity harness moved a 13 MiB payload on loopback using +1 MiB content chunks. Raw TCP reached 127.4 MiB/s, verified CultMesh TCP content +reached 59.0 MiB/s, raw MsQuic reached 51.5 MiB/s, and legacy CultMesh RUDP +reached 10.7 MiB/s while emitting 18,112,867 wire bytes. CultMesh TCP includes +chunk hashing, durable checkpoints, final hashing, and atomic promotion, so this +is not a claim of raw protocol parity. It is a repeatable workload comparison +and leaves the remaining framework overhead visible. diff --git a/src/GameCult.Networking/CultNetSchemaClient.cs b/src/GameCult.Networking/CultNetSchemaClient.cs index ba8fb699..4071cd1b 100644 --- a/src/GameCult.Networking/CultNetSchemaClient.cs +++ b/src/GameCult.Networking/CultNetSchemaClient.cs @@ -318,6 +318,9 @@ namespace GameCult.Networking return new RudpCultNetSchemaClient(runtimeId, connectionId, connectPayload, maxFragmentBytes, resendDelayMs); } + /// Creates the length-prefixed TCP schema client. + public static ICultNetSchemaClient CreateTcpFramed() => new TcpFramedCultNetSchemaClient(); + /// /// Creates the default schema client adapter for an advertised endpoint URI. /// @@ -341,12 +344,18 @@ namespace GameCult.Networking return CreateRudp(); } + if (string.Equals(uri.Scheme, "cultnet+tcp", StringComparison.OrdinalIgnoreCase)) + { + return CreateTcpFramed(); + } + if (string.Equals(uri.Scheme, "cultnet", StringComparison.OrdinalIgnoreCase)) { return CreateLiteNetLib(security, configureClient); } - throw new FormatException($"CultNet endpoint '{endpoint}' must use cultnet://host:port or rudp://host:port."); + throw new FormatException( + $"CultNet endpoint '{endpoint}' must use cultnet+tcp://host:port, cultnet://host:port, or rudp://host:port."); } } } diff --git a/src/GameCult.Networking/CultNetShardWriteForwarding.cs b/src/GameCult.Networking/CultNetShardWriteForwarding.cs index 44cb4582..b2b9e7a2 100644 --- a/src/GameCult.Networking/CultNetShardWriteForwarding.cs +++ b/src/GameCult.Networking/CultNetShardWriteForwarding.cs @@ -112,7 +112,8 @@ namespace GameCult.Networking !IsSupportedSchemaEndpointScheme(uri.Scheme) || string.IsNullOrWhiteSpace(uri.Host)) { - throw new FormatException($"CultNet endpoint '{endpoint}' must use cultnet://host:port or rudp://host:port."); + throw new FormatException( + $"CultNet endpoint '{endpoint}' must use cultnet+tcp://host:port, cultnet://host:port, or rudp://host:port."); } return (uri.Host, uri.IsDefaultPort ? 3075 : uri.Port); @@ -135,7 +136,8 @@ namespace GameCult.Networking private static bool IsSupportedSchemaEndpointScheme(string scheme) { - return string.Equals(scheme, "cultnet", StringComparison.OrdinalIgnoreCase) || + return string.Equals(scheme, "cultnet+tcp", StringComparison.OrdinalIgnoreCase) || + string.Equals(scheme, "cultnet", StringComparison.OrdinalIgnoreCase) || string.Equals(scheme, "rudp", StringComparison.OrdinalIgnoreCase); } diff --git a/src/GameCult.Networking/CultNetTcpSchemaTransport.cs b/src/GameCult.Networking/CultNetTcpSchemaTransport.cs new file mode 100644 index 00000000..4cdfe06c --- /dev/null +++ b/src/GameCult.Networking/CultNetTcpSchemaTransport.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace GameCult.Networking +{ + /// Schema-v0 client over the length-prefixed TCP lane. + public sealed class TcpFramedCultNetSchemaClient : ICultNetSchemaClient, ICultNetSchemaClientHealth + { + private readonly ConcurrentDictionary> _handlers = new(); + private readonly object _handlerGate = new(); + private readonly object _sendGate = new(); + private readonly TaskCompletionSource _backgroundFailure = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly CancellationTokenSource _shutdown = new(); + private TcpClient? _client; + private TcpFramedTransportConnection? _transport; + private bool _disposed; + + public bool Connected => !_disposed && _client?.Connected == true; + public Task BackgroundFailure => _backgroundFailure.Task; + + public void Connect(string host, int port) + { + if (_disposed) throw new ObjectDisposedException(nameof(TcpFramedCultNetSchemaClient)); + if (_client != null) throw new InvalidOperationException("TCP schema client is already connected."); + var client = new TcpClient(); + try + { + client.Connect(host, port); + _client = client; + _transport = new TcpFramedTransportConnection( + client.GetStream(), + CultNetTransportProfiles.CreateTcpFramed("csharp-tcp-schema-client")); + _ = ReceiveLoopAsync(_shutdown.Token); + } + catch + { + client.Dispose(); + throw; + } + } + + public void SendCultNet(T message) where T : ICultNetSchemaMessage + { + if (_disposed) throw new ObjectDisposedException(nameof(TcpFramedCultNetSchemaClient)); + var transport = _transport ?? throw new InvalidOperationException("TCP schema client is not connected."); + var payload = CultNetSchemaMessageSerialization.Serialize(message); + lock (_sendGate) + transport.SendAsync("schema", payload, _shutdown.Token).GetAwaiter().GetResult(); + } + + public void OnCultNet(Action callback) where T : ICultNetSchemaMessage + { + if (callback == null) throw new ArgumentNullException(nameof(callback)); + lock (_handlerGate) + { + if (!_handlers.TryGetValue(typeof(T), out var handlers)) + _handlers[typeof(T)] = handlers = new List(); + handlers.Add(callback); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _shutdown.Cancel(); + _client?.Dispose(); + _shutdown.Dispose(); + } + + private async Task ReceiveLoopAsync(CancellationToken cancellationToken) + { + try + { + var transport = _transport!; + while (!cancellationToken.IsCancellationRequested) + { + var frame = await transport.ReceiveAsync(cancellationToken).ConfigureAwait(false); + var message = CultNetSchemaMessageSerialization.Deserialize(frame.Payload); + Dispatch(message); + } + } + catch (Exception error) when (!_disposed) + { + _backgroundFailure.TrySetResult(error); + } + } + + private void Dispatch(ICultNetSchemaMessage message) + { + Delegate[] handlers; + lock (_handlerGate) + handlers = _handlers.TryGetValue(message.GetType(), out var registered) + ? registered.ToArray() + : Array.Empty(); + foreach (var handler in handlers) handler.DynamicInvoke(message); + } + } + + /// Schema-v0 server over the length-prefixed TCP lane. + public sealed class TcpFramedCultNetSchemaServer : ICultNetSchemaServer, ICultNetSchemaServerPeerLifecycle, IDisposable + { + private readonly TcpListener _listener; + private readonly ConcurrentDictionary _handlers = new(); + private readonly ConcurrentDictionary _peers = new(); + private readonly CancellationTokenSource _shutdown = new(); + private bool _disposed; + + public TcpFramedCultNetSchemaServer(TcpListener listener) + { + _listener = listener ?? throw new ArgumentNullException(nameof(listener)); + _listener.Start(); + _ = AcceptLoopAsync(_shutdown.Token); + } + + public event Action? PeerDisconnected; + public IPEndPoint LocalEndPoint => (IPEndPoint)_listener.LocalEndpoint; + + public void OnCultNet(Func callback) + where TMessage : ICultNetSchemaMessage + { + if (callback == null) throw new ArgumentNullException(nameof(callback)); + _handlers[typeof(TMessage)] = callback; + } + + public void RemoveCultNetMessageListener(Delegate callback) + where TMessage : ICultNetSchemaMessage + { + if (_handlers.TryGetValue(typeof(TMessage), out var existing) && existing == callback) + _handlers.TryRemove(typeof(TMessage), out _); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _shutdown.Cancel(); + _listener.Stop(); + foreach (var peer in _peers.Keys) peer.Dispose(); + _peers.Clear(); + _shutdown.Dispose(); + } + + private async Task AcceptLoopAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + var client = await _listener.AcceptTcpClientAsync().ConfigureAwait(false); + var peer = new TcpPeer(client); + _peers.TryAdd(peer, 0); + _ = ServePeerAsync(peer, cancellationToken); + } + } + catch (Exception) when (_disposed || cancellationToken.IsCancellationRequested) + { + } + } + + private async Task ServePeerAsync(TcpPeer peer, CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + var frame = await peer.Transport.ReceiveAsync(cancellationToken).ConfigureAwait(false); + var message = CultNetSchemaMessageSerialization.Deserialize(frame.Payload); + if (_handlers.TryGetValue(message.GetType(), out var handler)) + { + var result = handler.DynamicInvoke(message, peer) as Task; + if (result != null) await result.ConfigureAwait(false); + } + } + } + catch (Exception) when (_disposed || cancellationToken.IsCancellationRequested || !peer.Connected) + { + } + finally + { + if (_peers.TryRemove(peer, out _)) PeerDisconnected?.Invoke(peer); + peer.Dispose(); + } + } + + private sealed class TcpPeer : ICultNetSchemaServerPeer, IDisposable + { + private readonly TcpClient _client; + private readonly object _sendGate = new(); + + public TcpPeer(TcpClient client) + { + _client = client; + Transport = new TcpFramedTransportConnection( + client.GetStream(), + CultNetTransportProfiles.CreateTcpFramed("csharp-tcp-schema-server")); + } + + public bool Connected => _client.Connected; + public TcpFramedTransportConnection Transport { get; } + + public void SendCultNet(TMessage message) where TMessage : ICultNetSchemaMessage + { + var payload = CultNetSchemaMessageSerialization.Serialize(message); + lock (_sendGate) Transport.SendAsync("schema", payload).GetAwaiter().GetResult(); + } + + public void Dispose() => _client.Dispose(); + } + } +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshBodySessionTests.cs b/tests/GameCult.Mesh.Tests/CultMeshBodySessionTests.cs index 55e45207..57b8995a 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshBodySessionTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshBodySessionTests.cs @@ -156,6 +156,7 @@ public sealed class CultMeshBodySessionTests private readonly ICultNetSchemaClient _client; public LoopbackConnector(ICultNetSchemaClient client) => _client = client; public string ConnectorId => "loopback"; + public int Priority => 0; public int ConnectCount { get; private set; } public bool CanConnect(CultMeshTransportCandidate candidate) => true; public Task ConnectAsync(CultMeshTransportCandidate candidate, CultMeshProtocolId protocol, diff --git a/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs b/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs index b7c41dc9..1596b93a 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs @@ -227,6 +227,7 @@ public sealed class CultMeshClientTests { private int _connectCount; public string ConnectorId => "test"; + public int Priority => 0; public int ConnectCount => Volatile.Read(ref _connectCount); public bool CanConnect(CultMeshTransportCandidate candidate) => true; public Task ConnectAsync( @@ -254,6 +255,7 @@ public sealed class CultMeshClientTests public bool SuppressFirstSnapshot { get; set; } public bool SuppressFirstSubscriptionSnapshot { get; set; } public string ConnectorId => "document-test"; + public int Priority => 0; public int SubscribeCount => Clients.Sum(client => client.SubscribeCount); public bool CanConnect(CultMeshTransportCandidate candidate) => true; public Task ConnectAsync( diff --git a/tests/GameCult.Mesh.Tests/CultMeshContentSessionTests.cs b/tests/GameCult.Mesh.Tests/CultMeshContentSessionTests.cs index ed300552..3dda96cd 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshContentSessionTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshContentSessionTests.cs @@ -30,19 +30,23 @@ public sealed class CultMeshContentSessionTests } [Test] - public async Task ContentSessionTransfersLargeBodyWithoutSnapshotPayloadsAndReusesCommittedBody() + public async Task TcpContentSessionStreamsLargeBodyAndReusesCommittedBody() { var payload = Enumerable.Range(0, 700_000).Select(value => (byte)(value % 251)).ToArray(); var artifact = CultMesh.PackCdnArtifact("aetheria/world/windows", payload); using var providerCache = new CultCache(CultMesh.CreateCultCacheDocumentRegistry( typeof(CultMeshCdnArtifactManifest), typeof(CultMeshCdnArtifactChunk))); await CultMeshCdn.PublishAsync(providerCache, artifact); - var wireServer = new LoopbackSchemaServer(); - using var contentServer = new CultMeshContentServer(wireServer, providerCache); - var wireClient = new LoopbackSchemaClient(wireServer); - var connector = new LoopbackConnector(wireClient); - using var discovery = new CultMeshDiscoveryService(new[] { new RouteSource() }); - using var sessions = new CultMeshSessionManager(discovery, new[] { connector }); + using var contentServer = new CultMeshTcpContentServer( + new TcpListener(IPAddress.Loopback, 0), + providerCache); + var endpoint = $"{CultMeshTcpContentTransportConnector.Scheme}://127.0.0.1:{contentServer.LocalEndPoint.Port}"; + var connector = new CultMeshTcpContentTransportConnector(); + using var discovery = new CultMeshDiscoveryService(new[] { new RouteSource(endpoint) }); + using var sessions = new CultMeshSessionManager( + discovery, + Array.Empty(), + new ICultMeshContentTransportConnector[] { connector }); var provider = new CultMeshSessionContentProvider( "aetheria.daemon", sessions, @@ -55,16 +59,14 @@ public sealed class CultMeshContentSessionTests new CultMeshContentTransferOptions(_directory)); var first = await transfer.FetchAsync(artifact.Manifest); - var requestsAfterCold = wireClient.ContentRequestCount; + var requestsAfterCold = contentServer.ChunkRequestsServed; var second = await transfer.FetchAsync(artifact.Manifest); File.ReadAllBytes(first).Should().Equal(payload); second.Should().Be(first); requestsAfterCold.Should().Be(artifact.Manifest.Chunks.Length); - wireClient.ContentRequestCount.Should().Be(requestsAfterCold, + contentServer.ChunkRequestsServed.Should().Be(requestsAfterCold, "a committed verified body is the warm-cache authority"); - wireClient.SnapshotRequestCount.Should().Be(0, - "bulk content must never be represented as snapshot records"); connector.ConnectCount.Should().Be(1, "all chunk requests borrow one identity-first content session"); } @@ -86,7 +88,7 @@ public sealed class CultMeshContentSessionTests MaxFragmentBytes = 1024, MaxPendingReliablePackets = 8192 }); - using var contentServer = new CultMeshContentServer(wireServer, providerCache); + using var contentServer = new CultMeshLegacyRudpContentServer(wireServer, providerCache); using var pumpCancellation = new CancellationTokenSource(); var pump = Task.Run(async () => { @@ -101,14 +103,16 @@ public sealed class CultMeshContentSessionTests var endpoint = $"rudp://127.0.0.1:{wireServer.LocalEndPoint.Port}"; using var discovery = new CultMeshDiscoveryService(new[] { new RouteSource(endpoint) }); + var connector = new CultMeshLegacyRudpContentTransportConnector( + new CultMeshLegacyRudpContentTransportOptions { ResponseTimeout = TimeSpan.FromSeconds(20) }); using var sessions = new CultMeshSessionManager( discovery, - new ICultMeshTransportConnector[] { new CultMeshSchemaTransportConnector() }); + Array.Empty(), + new ICultMeshContentTransportConnector[] { connector }); var provider = new CultMeshSessionContentProvider( "aetheria.daemon", sessions, - CultMeshEndpointId.Parse("service:aetheria.daemon"), - new CultMeshSessionContentProviderOptions { ResponseTimeout = TimeSpan.FromSeconds(20) }); + CultMeshEndpointId.Parse("service:aetheria.daemon")); using var transferState = new CultCache(CultMesh.CreateCultCacheDocumentRegistry( typeof(CultMeshContentTransferStateDocument))); var transfer = new CultMeshContentTransferService( @@ -205,6 +209,7 @@ public sealed class CultMeshContentSessionTests private int _connectCount; public LoopbackConnector(ICultNetSchemaClient client) => _client = client; public string ConnectorId => "loopback"; + public int Priority => 0; public int ConnectCount => Volatile.Read(ref _connectCount); public bool CanConnect(CultMeshTransportCandidate candidate) => true; public Task ConnectAsync( diff --git a/tests/GameCult.Mesh.Tests/CultMeshContentTransferTests.cs b/tests/GameCult.Mesh.Tests/CultMeshContentTransferTests.cs index 8bbf43a9..04c6e283 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshContentTransferTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshContentTransferTests.cs @@ -365,7 +365,10 @@ public sealed class CultMeshContentTransferTests public void ReleaseBlockedChunk() => _releaseBlockedChunk.TrySetResult(true); - public async Task GetChunkAsync(CultMeshCdnChunkRef chunk, CancellationToken cancellationToken = default) + public async Task CopyChunkToAsync( + CultMeshCdnChunkRef chunk, + Stream destination, + CancellationToken cancellationToken = default) { Requests.AddOrUpdate(chunk.ChunkHash, 1, (_, count) => count + 1); var request = Interlocked.Increment(ref _requestCount); @@ -384,7 +387,9 @@ public sealed class CultMeshContentTransferTests } if (DelayMilliseconds > 0) await Task.Delay(DelayMilliseconds, cancellationToken); - return _chunks.TryGetValue(chunk.ChunkHash, out var value) ? Clone(value) : null; + if (!_chunks.TryGetValue(chunk.ChunkHash, out var value)) + throw new FileNotFoundException("Synthetic content chunk is missing.", chunk.RecordKey); + await destination.WriteAsync(value.Payload, cancellationToken); } finally { diff --git a/tests/GameCult.Mesh.Tests/CultMeshSessionManagerTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSessionManagerTests.cs index 4d03f09a..b0aabd03 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshSessionManagerTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshSessionManagerTests.cs @@ -243,6 +243,7 @@ public sealed class CultMeshSessionManagerTests private int _connectCount; public FakeConnector(Func> connect) { _connect = connect; } public string ConnectorId => "fake"; + public int Priority => 0; public int ConnectCount => Volatile.Read(ref _connectCount); public bool CanConnect(CultMeshTransportCandidate candidate) => true; public Task ConnectAsync(CultMeshTransportCandidate candidate, CultMeshProtocolId protocol, CancellationToken cancellationToken = default) diff --git a/tests/GameCult.Mesh.Tests/CultMeshTransportModularityTests.cs b/tests/GameCult.Mesh.Tests/CultMeshTransportModularityTests.cs new file mode 100644 index 00000000..a280c436 --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshTransportModularityTests.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using GameCult.Caching; +using GameCult.Networking; +using NUnit.Framework; + +namespace GameCult.Mesh.Tests; + +[TestFixture] +public sealed class CultMeshTransportModularityTests +{ + [Test] + public async Task SchemaSessionPrefersTcpTierWithoutTouchingLegacyDatagrams() + { + using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0)); + server.OnCultNet((message, peer) => + { + peer.SendCultNet(new CultNetErrorMessage { Error = "tcp:" + message.RuntimeId }); + return Task.CompletedTask; + }); + var tcpEndpoint = $"cultnet+tcp://127.0.0.1:{server.LocalEndPoint.Port}"; + var legacy = new RejectingLegacySchemaConnector(); + using var discovery = new CultMeshDiscoveryService(new[] + { + new RouteSource("rudp://127.0.0.1:9", tcpEndpoint) + }); + using var sessions = new CultMeshSessionManager( + discovery, + new ICultMeshTransportConnector[] + { + legacy, + new CultMeshTcpSchemaTransportConnector() + }); + + var session = await sessions.ConnectAsync( + CultMeshEndpointId.Parse("service:aetheria.daemon"), + CultMeshProtocols.Documents); + var response = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = session.OnCultNet(message => response.TrySetResult(message)); + session.SendCultNet(new CultNetHelloMessage { RuntimeId = "eve-unity" }); + var received = await response.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + session.State.Path!.Endpoint.Should().Be(tcpEndpoint); + received.Error.Should().Be("tcp:eve-unity"); + legacy.ConnectCount.Should().Be(0, + "a lower transport tier is fallback, not a race against the preferred TCP tier"); + } + + [Test] + public async Task ContentSessionPrefersTcpAndStreamsOutsideSchemaPayloads() + { + var payload = new byte[512 * 1024]; + new Random(1234).NextBytes(payload); + var artifact = CultMesh.PackCdnArtifact("transport/modularity", payload); + using var content = new CultCache(CultMesh.CreateCultCacheDocumentRegistry( + typeof(CultMeshCdnArtifactManifest), typeof(CultMeshCdnArtifactChunk))); + await CultMeshCdn.PublishAsync(content, artifact); + using var server = new CultMeshTcpContentServer(new TcpListener(IPAddress.Loopback, 0), content); + var tcpEndpoint = $"{CultMeshTcpContentTransportConnector.Scheme}://127.0.0.1:{server.LocalEndPoint.Port}"; + var legacy = new RejectingLegacyContentConnector(); + using var discovery = new CultMeshDiscoveryService(new[] + { + new RouteSource("rudp://127.0.0.1:9", tcpEndpoint) + }); + using var sessions = new CultMeshSessionManager( + discovery, + Array.Empty(), + new ICultMeshContentTransportConnector[] + { + legacy, + new CultMeshTcpContentTransportConnector() + }); + + var session = await sessions.ConnectContentAsync(CultMeshEndpointId.Parse("service:aetheria.daemon")); + using var destination = new MemoryStream(); + await session.CopyChunkToAsync(artifact.Manifest.Chunks[0], destination); + + session.TransportId.Should().Be("tcp-content"); + destination.ToArray().Should().Equal(artifact.Chunks[0].Payload); + legacy.ConnectCount.Should().Be(0); + } + + [Test] + public async Task ContentSessionFailsClosedWithoutAnExplicitConnector() + { + using var discovery = new CultMeshDiscoveryService(new[] + { + new RouteSource("rudp://127.0.0.1:3076") + }); + using var sessions = new CultMeshSessionManager( + discovery, + Array.Empty()); + + Func connect = async () => await sessions.ConnectContentAsync( + CultMeshEndpointId.Parse("service:aetheria.daemon")); + + await connect.Should().ThrowAsync() + .WithMessage("*No streaming content connectors are configured*"); + } + + [Test] + public async Task RealtimeSessionUsesDedicatedQuicShapedConnectorContract() + { + var connector = new LoopbackRealtimeConnector(); + using var discovery = new CultMeshDiscoveryService(new[] + { + new RouteSource("cultmesh-state+quic://127.0.0.1:3077") + }); + using var sessions = new CultMeshSessionManager( + discovery, + Array.Empty(), + Array.Empty(), + new ICultMeshRealtimeTransportConnector[] { connector }); + var session = await sessions.ConnectRealtimeAsync( + CultMeshEndpointId.Parse("service:aetheria.daemon")); + var sent = new CultMeshRealtimeFrame + { + ChannelId = "aetheria.entities", + SchemaId = "eve.entity_soa.v1", + BodyId = "body:aetheria:entities", + ProducerEpoch = 7, + Sequence = 42, + Delivery = CultMeshRealtimeDelivery.LatestOnly, + Payload = new byte[] { 1, 2, 3 } + }; + + await session.SendAsync(sent); + var received = await session.ReceiveAsync(); + + session.TransportId.Should().Be("test-quic-state"); + received.Should().BeSameAs(sent); + connector.ConnectCount.Should().Be(1); + } + + [Test] + public async Task RealtimeSessionFailsClosedWithoutAnExplicitConnector() + { + using var discovery = new CultMeshDiscoveryService(new[] + { + new RouteSource("rudp://127.0.0.1:3076") + }); + using var sessions = new CultMeshSessionManager( + discovery, + Array.Empty()); + + Func connect = async () => await sessions.ConnectRealtimeAsync( + CultMeshEndpointId.Parse("service:aetheria.daemon")); + + await connect.Should().ThrowAsync() + .WithMessage("*No realtime state connectors are configured*"); + } + + private sealed class RouteSource : ICultMeshLookupSource + { + private readonly string[] _endpoints; + + public RouteSource(params string[] endpoints) => _endpoints = endpoints; + public string SourceId => "transport-modularity-test"; + + public Task> LookupAsync( + CultMeshDiscoveryQuery query, + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + return Task.FromResult>(new[] + { + new CultMeshDiscoveryObservation( + new CultMeshVerseDescriptor( + "aetheria", + "Aetheria", + CultMeshVerseAuthorityModel.OperatorCluster, + new CultMeshVerseCompatibility("cultmesh.v1", "rules"), + _endpoints), + SourceId, + now, + now.AddMinutes(1), + CultMeshDiscoveryTrust.Signed) + }); + } + } + + private sealed class RejectingLegacySchemaConnector : ICultMeshTransportConnector + { + private int _connectCount; + public string ConnectorId => "test-legacy-schema"; + public int Priority => 10_000; + public int ConnectCount => Volatile.Read(ref _connectCount); + public bool CanConnect(CultMeshTransportCandidate candidate) => + candidate.Endpoint.StartsWith("rudp://", StringComparison.OrdinalIgnoreCase); + public Task ConnectAsync( + CultMeshTransportCandidate candidate, + CultMeshProtocolId protocol, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _connectCount); + throw new InvalidOperationException("Legacy schema connector should not be touched."); + } + } + + private sealed class RejectingLegacyContentConnector : ICultMeshContentTransportConnector + { + private int _connectCount; + public string ConnectorId => "test-legacy-content"; + public int Priority => 10_000; + public int ConnectCount => Volatile.Read(ref _connectCount); + public bool CanConnect(CultMeshTransportCandidate candidate) => + candidate.Endpoint.StartsWith("rudp://", StringComparison.OrdinalIgnoreCase); + public Task ConnectAsync( + CultMeshTransportCandidate candidate, + CultMeshEndpointId endpointId, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _connectCount); + throw new InvalidOperationException("Legacy content connector should not be touched."); + } + } + + private sealed class LoopbackRealtimeConnector : ICultMeshRealtimeTransportConnector + { + private int _connectCount; + public string ConnectorId => "test-quic-state"; + public int Priority => 0; + public int ConnectCount => Volatile.Read(ref _connectCount); + public bool CanConnect(CultMeshTransportCandidate candidate) => + candidate.Endpoint.StartsWith("cultmesh-state+quic://", StringComparison.OrdinalIgnoreCase); + public Task ConnectAsync( + CultMeshTransportCandidate candidate, + CultMeshEndpointId endpointId, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _connectCount); + return Task.FromResult(new LoopbackRealtimeTransport(candidate.Endpoint)); + } + } + + private sealed class LoopbackRealtimeTransport : ICultMeshRealtimeTransport + { + private readonly Queue _frames = new(); + private readonly SemaphoreSlim _available = new(0); + private bool _disposed; + + public LoopbackRealtimeTransport(string endpoint) => Endpoint = endpoint; + public string TransportId => "test-quic-state"; + public string Endpoint { get; } + + public Task SendAsync(CultMeshRealtimeFrame frame, CancellationToken cancellationToken = default) + { + if (_disposed) throw new ObjectDisposedException(nameof(LoopbackRealtimeTransport)); + lock (_frames) _frames.Enqueue(frame); + _available.Release(); + return Task.CompletedTask; + } + + public async Task ReceiveAsync(CancellationToken cancellationToken = default) + { + await _available.WaitAsync(cancellationToken); + lock (_frames) return _frames.Dequeue(); + } + + public void Dispose() + { + _disposed = true; + _available.Dispose(); + } + } +} diff --git a/tools/GameCult.TransportParity/Program.cs b/tools/GameCult.TransportParity/Program.cs index d729326a..2661e9fa 100644 --- a/tools/GameCult.TransportParity/Program.cs +++ b/tools/GameCult.TransportParity/Program.cs @@ -20,6 +20,7 @@ Console.WriteLine($"payloadBytes={payloadBytes} quicSupported={QuicListener.IsSu Print(MeasureMappedFile(payload)); var tcp = await MeasureTcpAsync(payload, expectedHash); Print(tcp); +Print(await MeasureCultMeshTcpAsync(payload)); if (QuicListener.IsSupported) Print(await MeasureQuicAsync(payload, expectedHash)); @@ -153,7 +154,8 @@ static async Task MeasureQuicAsync(byte[] payload, string expectedHash) static async Task MeasureCultMeshRudpAsync(byte[] payload) { - var artifact = CultMesh.PackCdnArtifact("parity/body", payload); + var artifact = CultMesh.PackCdnArtifact( + "parity/body", payload, new CultMeshCdnPackOptions { ChunkSizeBytes = 1024 * 1024 }); using var providerCache = new CultCache(CultMesh.CreateCultCacheDocumentRegistry( typeof(CultMeshCdnArtifactManifest), typeof(CultMeshCdnArtifactChunk))); await CultMeshCdn.PublishAsync(providerCache, artifact); @@ -167,7 +169,7 @@ static async Task MeasureCultMeshRudpAsync(byte[] payload) MaxFragmentBytes = 1024, MaxPendingReliablePackets = 8192 }); - using var contentServer = new CultMeshContentServer(wireServer, providerCache); + using var contentServer = new CultMeshLegacyRudpContentServer(wireServer, providerCache); using var pumpCancellation = new CancellationTokenSource(); var pump = Task.Run(async () => { @@ -183,10 +185,12 @@ static async Task MeasureCultMeshRudpAsync(byte[] payload) var endpoint = $"rudp://127.0.0.1:{wireServer.LocalEndPoint.Port}"; using var discovery = new CultMeshDiscoveryService([new RouteSource(endpoint)]); using var sessions = new CultMeshSessionManager( - discovery, [new CultMeshSchemaTransportConnector()]); + discovery, + Array.Empty(), + [new CultMeshLegacyRudpContentTransportConnector( + new CultMeshLegacyRudpContentTransportOptions { ResponseTimeout = TimeSpan.FromMinutes(2) })]); var provider = new CultMeshSessionContentProvider( - "parity.provider", sessions, CultMeshEndpointId.Parse("service:parity.provider"), - new CultMeshSessionContentProviderOptions { ResponseTimeout = TimeSpan.FromMinutes(2) }); + "parity.provider", sessions, CultMeshEndpointId.Parse("service:parity.provider")); var directory = Path.Combine(Path.GetTempPath(), "cultmesh-transport-parity", Guid.NewGuid().ToString("N")); using var transferState = new CultCache(CultMesh.CreateCultCacheDocumentRegistry( typeof(CultMeshContentTransferStateDocument))); @@ -210,6 +214,45 @@ static async Task MeasureCultMeshRudpAsync(byte[] payload) } } +static async Task MeasureCultMeshTcpAsync(byte[] payload) +{ + var artifact = CultMesh.PackCdnArtifact( + "parity/body", payload, new CultMeshCdnPackOptions { ChunkSizeBytes = 1024 * 1024 }); + using var providerCache = new CultCache(CultMesh.CreateCultCacheDocumentRegistry( + typeof(CultMeshCdnArtifactManifest), typeof(CultMeshCdnArtifactChunk))); + await CultMeshCdn.PublishAsync(providerCache, artifact); + + using var contentServer = new CultMeshTcpContentServer( + new TcpListener(IPAddress.Loopback, 0), providerCache); + var endpoint = $"{CultMeshTcpContentTransportConnector.Scheme}://127.0.0.1:{contentServer.LocalEndPoint.Port}"; + using var discovery = new CultMeshDiscoveryService([new RouteSource(endpoint)]); + using var sessions = new CultMeshSessionManager( + discovery, + Array.Empty(), + [new CultMeshTcpContentTransportConnector()]); + var provider = new CultMeshSessionContentProvider( + "parity.provider", sessions, CultMeshEndpointId.Parse("service:parity.provider")); + var directory = Path.Combine(Path.GetTempPath(), "cultmesh-transport-parity", Guid.NewGuid().ToString("N")); + using var transferState = new CultCache(CultMesh.CreateCultCacheDocumentRegistry( + typeof(CultMeshContentTransferStateDocument))); + var transfer = new CultMeshContentTransferService( + transferState, [provider], new CultMeshContentTransferOptions(directory)); + + var elapsed = Stopwatch.StartNew(); + try + { + var path = await transfer.FetchAsync(artifact.Manifest); + elapsed.Stop(); + if (!File.ReadAllBytes(path).AsSpan().SequenceEqual(payload)) + throw new InvalidDataException("CultMesh TCP payload did not match source."); + return Result.Create("cultmesh-tcp-content", payload.LongLength, elapsed.Elapsed); + } + finally + { + if (Directory.Exists(directory)) Directory.Delete(directory, true); + } +} + static X509Certificate2 CreateCertificate() { using var key = RSA.Create(2048);