1220 lines
56 KiB
C#
1220 lines
56 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using GameCult.Networking;
|
|
using R3;
|
|
|
|
namespace GameCult.Mesh
|
|
{
|
|
public sealed class CultMeshRuntimeId : IEquatable<CultMeshRuntimeId>
|
|
{
|
|
private CultMeshRuntimeId(string value) { Value = value; }
|
|
public string Value { get; }
|
|
public static CultMeshRuntimeId Parse(string value) => new CultMeshRuntimeId(
|
|
string.IsNullOrWhiteSpace(value) ? throw new FormatException("CultMesh runtime identity must be non-empty.") : value.Trim());
|
|
public bool Equals(CultMeshRuntimeId? other) => other != null && string.Equals(Value, other.Value, StringComparison.Ordinal);
|
|
public override bool Equals(object? obj) => Equals(obj as CultMeshRuntimeId);
|
|
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value);
|
|
public override string ToString() => Value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Identifies one provider inside one Verse. Odin resolves this stable pair to
|
|
/// disposable physical routes; neither value is a socket address.
|
|
/// </summary>
|
|
public sealed class CultMeshSessionTarget : IEquatable<CultMeshSessionTarget>
|
|
{
|
|
/// <summary>Creates one stable provider target inside one Verse.</summary>
|
|
public CultMeshSessionTarget(string verseId, string authorityRuntimeId)
|
|
{
|
|
VerseId = string.IsNullOrWhiteSpace(verseId)
|
|
? throw new ArgumentException("Verse identity is required.", nameof(verseId))
|
|
: verseId.Trim();
|
|
AuthorityRuntimeId = CultMeshRuntimeId.Parse(authorityRuntimeId).Value;
|
|
}
|
|
|
|
/// <summary>Gets the stable Verse identity.</summary>
|
|
public string VerseId { get; }
|
|
/// <summary>Gets the stable authority runtime identity advertised by that Verse.</summary>
|
|
public string AuthorityRuntimeId { get; }
|
|
/// <summary>Compatibility projection. Use <see cref="AuthorityRuntimeId"/>.</summary>
|
|
[Obsolete("Use AuthorityRuntimeId. Provider identity is not a physical endpoint.")]
|
|
public string ProviderRuntimeId => AuthorityRuntimeId;
|
|
internal string SessionKey => VerseId + "\u001f" + AuthorityRuntimeId;
|
|
|
|
public bool Equals(CultMeshSessionTarget? other) =>
|
|
other != null &&
|
|
string.Equals(VerseId, other.VerseId, StringComparison.Ordinal) &&
|
|
string.Equals(AuthorityRuntimeId, other.AuthorityRuntimeId, StringComparison.Ordinal);
|
|
public override bool Equals(object? obj) => Equals(obj as CultMeshSessionTarget);
|
|
public override int GetHashCode() => HashCode.Combine(
|
|
StringComparer.Ordinal.GetHashCode(VerseId),
|
|
StringComparer.Ordinal.GetHashCode(AuthorityRuntimeId));
|
|
public override string ToString() => VerseId + "/" + AuthorityRuntimeId;
|
|
}
|
|
|
|
public sealed class CultMeshProtocolId : IEquatable<CultMeshProtocolId>
|
|
{
|
|
public CultMeshProtocolId(string value) { Value = string.IsNullOrWhiteSpace(value) ? throw new ArgumentException("Protocol identity is required.", nameof(value)) : value; }
|
|
public string Value { get; }
|
|
public bool Equals(CultMeshProtocolId? other) => other != null && string.Equals(Value, other.Value, StringComparison.Ordinal);
|
|
public override bool Equals(object? obj) => Equals(obj as CultMeshProtocolId);
|
|
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value);
|
|
public override string ToString() => Value;
|
|
}
|
|
|
|
public static class CultMeshProtocols
|
|
{
|
|
public static CultMeshProtocolId Documents { get; } = new CultMeshProtocolId("cultmesh.documents.v1");
|
|
public static CultMeshProtocolId Content { get; } = new CultMeshProtocolId("cultmesh.content.v1");
|
|
public static CultMeshProtocolId Bodies { get; } = new CultMeshProtocolId("cultmesh.bodies.v1");
|
|
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 }
|
|
|
|
public sealed class CultMeshTransportCandidate
|
|
{
|
|
public CultMeshTransportCandidate(
|
|
string endpoint,
|
|
CultMeshTransportPathKind pathKind = CultMeshTransportPathKind.Direct,
|
|
int priority = 0,
|
|
string authorityRuntimeId = "",
|
|
string generation = "",
|
|
CultMeshAuthorityRoute? authorityRoute = null)
|
|
{
|
|
Endpoint = string.IsNullOrWhiteSpace(endpoint) ? throw new ArgumentException("Physical endpoint is required.", nameof(endpoint)) : endpoint;
|
|
PathKind = pathKind;
|
|
Priority = priority;
|
|
AuthorityRuntimeId = authorityRuntimeId?.Trim() ?? string.Empty;
|
|
Generation = generation?.Trim() ?? string.Empty;
|
|
AuthorityRoute = authorityRoute;
|
|
}
|
|
public string Endpoint { get; }
|
|
public CultMeshTransportPathKind PathKind { get; }
|
|
public int Priority { get; }
|
|
public string AuthorityRuntimeId { get; }
|
|
public string Generation { get; }
|
|
public CultMeshAuthorityRoute? AuthorityRoute { get; }
|
|
internal CultMeshVerifiedAuthorityRoute? VerifiedAuthority { get; private set; }
|
|
internal CultMeshTransportCandidate WithVerifiedAuthority(CultMeshVerifiedAuthorityRoute verified)
|
|
{
|
|
VerifiedAuthority = verified ?? throw new ArgumentNullException(nameof(verified));
|
|
return this;
|
|
}
|
|
}
|
|
|
|
public enum CultMeshSessionStatus { Connecting, Online, Degraded, Reconnecting, Offline }
|
|
public enum CultMeshSessionFailureReason { Resolution, Authentication, Transport, Protocol, Timeout, Cancellation, Authority, UnsupportedPath }
|
|
|
|
public sealed class CultMeshSessionFailure
|
|
{
|
|
public CultMeshSessionFailure(CultMeshSessionFailureReason reason, string message, string endpoint = "")
|
|
{
|
|
Reason = reason;
|
|
Message = message ?? "";
|
|
Endpoint = endpoint ?? "";
|
|
}
|
|
public CultMeshSessionFailureReason Reason { get; }
|
|
public string Message { get; }
|
|
public string Endpoint { get; }
|
|
}
|
|
|
|
public sealed class CultMeshSessionState
|
|
{
|
|
public CultMeshSessionState(
|
|
CultMeshSessionStatus status,
|
|
DateTimeOffset observedAtUtc,
|
|
CultMeshTransportCandidate? path = null,
|
|
CultMeshSessionFailure? failure = null)
|
|
{
|
|
Status = status;
|
|
ObservedAtUtc = observedAtUtc;
|
|
Path = path;
|
|
Failure = failure;
|
|
}
|
|
public CultMeshSessionStatus Status { get; }
|
|
public DateTimeOffset ObservedAtUtc { get; }
|
|
public CultMeshTransportCandidate? Path { get; }
|
|
public CultMeshSessionFailure? Failure { get; }
|
|
}
|
|
|
|
public sealed class CultMeshSessionException : Exception
|
|
{
|
|
public CultMeshSessionException(CultMeshSessionFailure failure, Exception? inner = null)
|
|
: base(failure?.Message, inner) { Failure = failure ?? throw new ArgumentNullException(nameof(failure)); }
|
|
public CultMeshSessionFailure Failure { get; }
|
|
}
|
|
|
|
public interface ICultMeshTransportConnector
|
|
{
|
|
string ConnectorId { get; }
|
|
int Priority { get; }
|
|
bool CanConnect(CultMeshTransportCandidate candidate);
|
|
Task<ICultNetSchemaClient> ConnectAsync(
|
|
CultMeshTransportCandidate candidate,
|
|
CultMeshProtocolId protocol,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Connects exact URI routes through a caller-supplied URI-capable CultNet client.
|
|
/// This keeps transport packages outside CultMesh while preserving advertised paths.
|
|
/// </summary>
|
|
public sealed class CultMeshUriSchemaTransportConnector : ICultMeshTransportConnector
|
|
{
|
|
private readonly HashSet<string> _schemes;
|
|
private readonly Func<string, ICultNetUriSchemaClient> _createClient;
|
|
|
|
public CultMeshUriSchemaTransportConnector(
|
|
string connectorId,
|
|
IEnumerable<string> schemes,
|
|
Func<string, ICultNetUriSchemaClient> createClient,
|
|
int priority = 0)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(connectorId))
|
|
throw new ArgumentException("Connector identity is required.", nameof(connectorId));
|
|
ConnectorId = connectorId.Trim();
|
|
_schemes = new HashSet<string>(
|
|
(schemes ?? throw new ArgumentNullException(nameof(schemes)))
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Select(value => value.Trim()),
|
|
StringComparer.OrdinalIgnoreCase);
|
|
if (_schemes.Count == 0)
|
|
throw new ArgumentException("At least one URI scheme is required.", nameof(schemes));
|
|
_createClient = createClient ?? throw new ArgumentNullException(nameof(createClient));
|
|
Priority = priority;
|
|
}
|
|
|
|
public string ConnectorId { get; }
|
|
public int Priority { get; }
|
|
|
|
public bool CanConnect(CultMeshTransportCandidate candidate) =>
|
|
candidate != null &&
|
|
Uri.TryCreate(candidate.Endpoint, UriKind.Absolute, out var uri) &&
|
|
_schemes.Contains(uri.Scheme);
|
|
|
|
public async Task<ICultNetSchemaClient> ConnectAsync(
|
|
CultMeshTransportCandidate candidate,
|
|
CultMeshProtocolId protocol,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!CanConnect(candidate))
|
|
throw new NotSupportedException($"Connector '{ConnectorId}' does not support '{candidate?.Endpoint}'.");
|
|
var endpoint = new Uri(candidate.Endpoint, UriKind.Absolute);
|
|
var client = _createClient(candidate.Endpoint)
|
|
?? throw new InvalidOperationException($"Connector '{ConnectorId}' returned no schema client.");
|
|
try
|
|
{
|
|
await client.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false);
|
|
return client;
|
|
}
|
|
catch
|
|
{
|
|
client.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Explicit compatibility connector for the previous RUDP/LiteNetLib schema lanes.
|
|
/// Prefer <see cref="CultMeshTcpSchemaTransportConnector"/> for registered schemas.
|
|
/// </summary>
|
|
public sealed class CultMeshSchemaTransportConnector : ICultMeshTransportConnector
|
|
{
|
|
private readonly Func<string, ICultNetSchemaClient> _createClient;
|
|
private readonly ICultMeshClock _clock;
|
|
private readonly TimeSpan _connectTimeout;
|
|
|
|
public CultMeshSchemaTransportConnector(
|
|
Func<string, ICultNetSchemaClient>? createClient = null,
|
|
ICultMeshClock? clock = null,
|
|
TimeSpan? connectTimeout = null)
|
|
{
|
|
_createClient = createClient ?? (endpoint => CultNetSchemaClients.CreateForEndpoint(endpoint));
|
|
_clock = clock ?? CultMeshSystemClock.Instance;
|
|
_connectTimeout = connectTimeout ?? TimeSpan.FromSeconds(5);
|
|
}
|
|
|
|
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) ||
|
|
string.Equals(uri.Scheme, "cultnet", StringComparison.OrdinalIgnoreCase));
|
|
|
|
public async Task<ICultNetSchemaClient> ConnectAsync(
|
|
CultMeshTransportCandidate candidate,
|
|
CultMeshProtocolId protocol,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!CanConnect(candidate)) throw new NotSupportedException($"Unsupported CultNet endpoint '{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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Preferred connector for registered schema, command, receipt, and manifest traffic.</summary>
|
|
public sealed class CultMeshTcpSchemaTransportConnector : ICultMeshTransportConnector
|
|
{
|
|
private readonly Func<string, ICultNetSchemaClient> _createClient;
|
|
private readonly ICultMeshClock _clock;
|
|
private readonly TimeSpan _connectTimeout;
|
|
|
|
public CultMeshTcpSchemaTransportConnector(
|
|
Func<string, ICultNetSchemaClient>? 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<ICultNetSchemaClient> 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<CultMeshSessionState> _states = new();
|
|
private readonly ManagedSchemaChannel _channel;
|
|
private long _physicalGeneration;
|
|
private bool _disposed;
|
|
internal CultMeshSession(CultMeshSessionTarget target, CultMeshProtocolId protocol, ICultNetSchemaClient channel, CultMeshSessionState state)
|
|
{
|
|
Target = target;
|
|
Protocol = protocol;
|
|
_channel = new ManagedSchemaChannel(channel);
|
|
State = state;
|
|
}
|
|
public CultMeshSessionTarget Target { get; }
|
|
public CultMeshProtocolId Protocol { get; }
|
|
internal ICultNetSchemaClient Channel => _channel;
|
|
public CultMeshSessionState State { get; private set; }
|
|
internal long PhysicalGeneration => Interlocked.Read(ref _physicalGeneration);
|
|
public Observable<CultMeshSessionState> WatchState() => _states;
|
|
public ICultNetSchemaClient OpenSchemaClient() => new SessionSchemaClient(this);
|
|
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage => Channel.SendCultNet(message);
|
|
public IDisposable OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
|
|
{
|
|
if (callback == null) throw new ArgumentNullException(nameof(callback));
|
|
return _channel.Subscribe(callback);
|
|
}
|
|
internal void Transition(CultMeshSessionState state) { State = state; _states.OnNext(state); }
|
|
internal void ReplacePhysicalChannel(ICultNetSchemaClient channel)
|
|
{
|
|
_channel.Replace(channel);
|
|
Interlocked.Increment(ref _physicalGeneration);
|
|
}
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_channel.Dispose();
|
|
_states.Dispose();
|
|
}
|
|
|
|
private sealed class ManagedSchemaChannel : ICultNetSchemaClient
|
|
{
|
|
private readonly object _gate = new();
|
|
private readonly Dictionary<Type, IChannelRegistration> _registrations = new();
|
|
private ICultNetSchemaClient _physical;
|
|
private bool _disposed;
|
|
|
|
public ManagedSchemaChannel(ICultNetSchemaClient physical) =>
|
|
_physical = physical ?? throw new ArgumentNullException(nameof(physical));
|
|
|
|
public bool Connected { get { lock (_gate) return !_disposed && _physical.Connected; } }
|
|
public void Connect(string host, int port)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
ThrowIfDisposed();
|
|
_physical.Connect(host, port);
|
|
}
|
|
}
|
|
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage
|
|
{
|
|
lock (_gate)
|
|
{
|
|
ThrowIfDisposed();
|
|
_physical.SendCultNet(message);
|
|
}
|
|
}
|
|
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
|
|
{
|
|
_ = Subscribe(callback);
|
|
}
|
|
public IDisposable Subscribe<T>(Action<T> callback) where T : ICultNetSchemaMessage
|
|
{
|
|
if (callback == null) throw new ArgumentNullException(nameof(callback));
|
|
lock (_gate)
|
|
{
|
|
ThrowIfDisposed();
|
|
if (!_registrations.TryGetValue(typeof(T), out var existing))
|
|
{
|
|
var created = new ChannelRegistration<T>();
|
|
created.Attach(_physical);
|
|
_registrations.Add(typeof(T), created);
|
|
existing = created;
|
|
}
|
|
return ((ChannelRegistration<T>)existing).Subscribe(callback);
|
|
}
|
|
}
|
|
public void Replace(ICultNetSchemaClient physical)
|
|
{
|
|
if (physical == null) throw new ArgumentNullException(nameof(physical));
|
|
ICultNetSchemaClient previous;
|
|
lock (_gate)
|
|
{
|
|
ThrowIfDisposed();
|
|
previous = _physical;
|
|
foreach (var registration in _registrations.Values) registration.Attach(physical);
|
|
_physical = physical;
|
|
}
|
|
previous.Dispose();
|
|
}
|
|
public void Dispose()
|
|
{
|
|
ICultNetSchemaClient physical;
|
|
lock (_gate)
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
physical = _physical;
|
|
foreach (var registration in _registrations.Values) registration.Dispose();
|
|
_registrations.Clear();
|
|
}
|
|
physical.Dispose();
|
|
}
|
|
private void ThrowIfDisposed() { if (_disposed) throw new ObjectDisposedException(nameof(ManagedSchemaChannel)); }
|
|
|
|
private interface IChannelRegistration : IDisposable { void Attach(ICultNetSchemaClient physical); }
|
|
private sealed class ChannelRegistration<T> : IChannelRegistration where T : ICultNetSchemaMessage
|
|
{
|
|
private readonly object _gate = new();
|
|
private readonly Dictionary<long, Action<T>> _callbacks = new();
|
|
private long _nextId;
|
|
private bool _disposed;
|
|
|
|
public IDisposable Subscribe(Action<T> callback)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_disposed) throw new ObjectDisposedException(nameof(ChannelRegistration<T>));
|
|
var id = ++_nextId;
|
|
_callbacks.Add(id, callback);
|
|
return new Subscription(this, id);
|
|
}
|
|
}
|
|
|
|
public void Attach(ICultNetSchemaClient physical) => physical.OnCultNet<T>(Dispatch);
|
|
|
|
public void Dispose()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
_disposed = true;
|
|
_callbacks.Clear();
|
|
}
|
|
}
|
|
|
|
private void Dispatch(T message)
|
|
{
|
|
Action<T>[] callbacks;
|
|
lock (_gate) callbacks = _callbacks.Values.ToArray();
|
|
foreach (var callback in callbacks) callback(message);
|
|
}
|
|
|
|
private void Unsubscribe(long id)
|
|
{
|
|
lock (_gate) _callbacks.Remove(id);
|
|
}
|
|
|
|
private sealed class Subscription : IDisposable
|
|
{
|
|
private ChannelRegistration<T>? _owner;
|
|
private readonly long _id;
|
|
|
|
public Subscription(ChannelRegistration<T> owner, long id)
|
|
{
|
|
_owner = owner;
|
|
_id = id;
|
|
}
|
|
|
|
public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Unsubscribe(_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class SessionSchemaClient : ICultNetSchemaClient
|
|
{
|
|
private readonly CultMeshSession _session;
|
|
private readonly ConcurrentBag<IDisposable> _registrations = new();
|
|
private bool _disposed;
|
|
public SessionSchemaClient(CultMeshSession session) { _session = session; }
|
|
public bool Connected => !_disposed && _session.State.Status == CultMeshSessionStatus.Online;
|
|
public void Connect(string host, int port)
|
|
{
|
|
if (_disposed) throw new ObjectDisposedException(nameof(SessionSchemaClient));
|
|
if (!Connected) throw new InvalidOperationException("The managed CultMesh session is not online.");
|
|
}
|
|
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage
|
|
{
|
|
if (_disposed) throw new ObjectDisposedException(nameof(SessionSchemaClient));
|
|
_session.SendCultNet(message);
|
|
}
|
|
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
|
|
{
|
|
if (_disposed) throw new ObjectDisposedException(nameof(SessionSchemaClient));
|
|
_registrations.Add(_session.OnCultNet(callback));
|
|
}
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
foreach (var registration in _registrations) registration.Dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class CultMeshSessionManagerOptions
|
|
{
|
|
public ICultMeshClock Clock { get; set; } = CultMeshSystemClock.Instance;
|
|
public ICultMeshDiagnosticSink Diagnostics { get; set; } = CultMeshNullDiagnosticSink.Instance;
|
|
public int MaxRacedCandidates { get; set; } = 2;
|
|
public string RuntimeId { get; set; } = "cultmesh-client";
|
|
public TimeSpan IdentityHandshakeTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
|
/// <summary>Gets or sets the consumer-owned trust roots and explicit local-development policy.</summary>
|
|
public CultMeshAuthorityTrustPolicy Trust { get; set; } = new(
|
|
CultMeshAuthorityTrustMode.AuthenticatedRemote);
|
|
}
|
|
|
|
public sealed class CultMeshSessionManager : IDisposable
|
|
{
|
|
private readonly CultMeshDiscoveryService _discovery;
|
|
private readonly ICultMeshTransportConnector[] _connectors;
|
|
private readonly ICultMeshContentTransportConnector[] _contentConnectors;
|
|
private readonly ICultMeshRealtimeTransportConnector[] _realtimeConnectors;
|
|
private readonly CultMeshSessionManagerOptions _options;
|
|
private readonly ConcurrentDictionary<string, Lazy<Task<CultMeshSession>>> _sessions = new(StringComparer.Ordinal);
|
|
private readonly ConcurrentDictionary<string, Lazy<Task<CultMeshContentSession>>> _contentSessions = new(StringComparer.Ordinal);
|
|
private readonly ConcurrentDictionary<string, Lazy<Task<CultMeshRealtimeSession>>> _realtimeSessions = new(StringComparer.Ordinal);
|
|
private long _diagnosticSequence;
|
|
private bool _disposed;
|
|
|
|
public CultMeshSessionManager(
|
|
CultMeshDiscoveryService discovery,
|
|
IEnumerable<ICultMeshTransportConnector> connectors,
|
|
CultMeshSessionManagerOptions? options = null)
|
|
: this(
|
|
discovery,
|
|
connectors,
|
|
Array.Empty<ICultMeshContentTransportConnector>(),
|
|
Array.Empty<ICultMeshRealtimeTransportConnector>(),
|
|
options)
|
|
{
|
|
}
|
|
|
|
public CultMeshSessionManager(
|
|
CultMeshDiscoveryService discovery,
|
|
IEnumerable<ICultMeshTransportConnector> connectors,
|
|
IEnumerable<ICultMeshContentTransportConnector> contentConnectors,
|
|
CultMeshSessionManagerOptions? options = null)
|
|
: this(
|
|
discovery,
|
|
connectors,
|
|
contentConnectors,
|
|
Array.Empty<ICultMeshRealtimeTransportConnector>(),
|
|
options)
|
|
{
|
|
}
|
|
|
|
public CultMeshSessionManager(
|
|
CultMeshDiscoveryService discovery,
|
|
IEnumerable<ICultMeshTransportConnector> connectors,
|
|
IEnumerable<ICultMeshContentTransportConnector> contentConnectors,
|
|
IEnumerable<ICultMeshRealtimeTransportConnector> 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));
|
|
if (string.IsNullOrWhiteSpace(_options.RuntimeId)) throw new ArgumentException("Client runtime identity is required.", nameof(options));
|
|
if (_options.IdentityHandshakeTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(options));
|
|
}
|
|
|
|
public async Task<CultMeshSession> ConnectAsync(
|
|
CultMeshSessionTarget target,
|
|
CultMeshProtocolId protocol,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (target == null) throw new ArgumentNullException(nameof(target));
|
|
if (protocol == null) throw new ArgumentNullException(nameof(protocol));
|
|
ThrowIfDisposed();
|
|
var key = target.SessionKey + "\u001f" + protocol.Value;
|
|
while (true)
|
|
{
|
|
var lazy = _sessions.GetOrAdd(key, _ => new Lazy<Task<CultMeshSession>>(
|
|
() => ConnectOwnedAsync(key, target, protocol), LazyThreadSafetyMode.ExecutionAndPublication));
|
|
try
|
|
{
|
|
var session = await AwaitForCallerAsync(lazy.Value, cancellationToken).ConfigureAwait(false);
|
|
if (session.State.Status != CultMeshSessionStatus.Offline) return session;
|
|
_sessions.TryRemove(key, out _);
|
|
}
|
|
catch
|
|
{
|
|
_sessions.TryRemove(key, out _);
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Connects one reusable streaming content plane by stable Verse/provider identity.
|
|
/// Preferred connector tiers are exhausted before legacy fallback tiers are attempted.
|
|
/// </summary>
|
|
public async Task<CultMeshContentSession> ConnectContentAsync(
|
|
CultMeshSessionTarget target,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (target == null) throw new ArgumentNullException(nameof(target));
|
|
ThrowIfDisposed();
|
|
var key = target.SessionKey;
|
|
while (true)
|
|
{
|
|
var lazy = _contentSessions.GetOrAdd(key, _ => new Lazy<Task<CultMeshContentSession>>(
|
|
() => ConnectContentOwnedAsync(key, target), 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<CultMeshContentSession> ConnectContentOwnedAsync(
|
|
string key,
|
|
CultMeshSessionTarget target)
|
|
{
|
|
var result = await ResolveContentPathAsync(target).ConfigureAwait(false);
|
|
var session = new CultMeshContentSession(
|
|
target,
|
|
result.Transport,
|
|
new CultMeshSessionState(CultMeshSessionStatus.Online, _options.Clock.UtcNow, result.Candidate),
|
|
failed => InvalidateContentSession(key, failed));
|
|
Emit(target, CultMeshProtocols.Content, "online", result.Candidate.Endpoint);
|
|
return session;
|
|
}
|
|
|
|
private async Task<ConnectedContentPath> ResolveContentPathAsync(CultMeshSessionTarget target)
|
|
{
|
|
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(
|
|
target.AuthorityRuntimeId,
|
|
new[] { target.VerseId },
|
|
target.AuthorityRuntimeId)).ConfigureAwait(false);
|
|
var candidates = BoundCandidates(discovery, target, CultMeshProtocols.Content);
|
|
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 '{target}'.");
|
|
|
|
var failures = new List<Exception>();
|
|
foreach (var tier in routes.GroupBy(route => route.Connector.Priority).OrderBy(group => group.Key))
|
|
{
|
|
var attempts = tier.Take(_options.MaxRacedCandidates)
|
|
.Select(route => ConnectContentCandidateAsync(target, 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();
|
|
var reason = last is CultMeshSessionException typed
|
|
? typed.Failure.Reason
|
|
: last is TimeoutException
|
|
? CultMeshSessionFailureReason.Timeout
|
|
: CultMeshSessionFailureReason.Transport;
|
|
throw Failure(
|
|
reason,
|
|
$"No streaming content path connected for '{target}'.",
|
|
last);
|
|
}
|
|
|
|
private static async Task<ConnectedContentPath> ConnectContentCandidateAsync(
|
|
CultMeshSessionTarget target,
|
|
ContentRoute route)
|
|
{
|
|
var transport = await route.Connector.ConnectAsync(route.Candidate, target).ConfigureAwait(false);
|
|
try
|
|
{
|
|
CultMeshTransportIdentity.RequireVerified(
|
|
transport, target, CultMeshProtocols.Content, route.Candidate);
|
|
return new ConnectedContentPath(route.Candidate, transport);
|
|
}
|
|
catch
|
|
{
|
|
transport.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
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.Target, CultMeshProtocols.Content, "offline", session.State.Path?.Endpoint ?? string.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Connects the realtime state plane. No connector is implied: promoted runtimes
|
|
/// register QUIC explicitly, while RUDP compatibility remains opt-in.
|
|
/// </summary>
|
|
public async Task<CultMeshRealtimeSession> ConnectRealtimeAsync(
|
|
CultMeshSessionTarget target,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (target == null) throw new ArgumentNullException(nameof(target));
|
|
ThrowIfDisposed();
|
|
var key = target.SessionKey;
|
|
while (true)
|
|
{
|
|
var lazy = _realtimeSessions.GetOrAdd(key, _ => new Lazy<Task<CultMeshRealtimeSession>>(
|
|
() => ConnectRealtimeOwnedAsync(key, target), 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<CultMeshRealtimeSession> ConnectRealtimeOwnedAsync(
|
|
string key,
|
|
CultMeshSessionTarget target)
|
|
{
|
|
var result = await ResolveRealtimePathAsync(target).ConfigureAwait(false);
|
|
var session = new CultMeshRealtimeSession(
|
|
target,
|
|
result.Transport,
|
|
new CultMeshSessionState(CultMeshSessionStatus.Online, _options.Clock.UtcNow, result.Candidate),
|
|
failed => InvalidateRealtimeSession(key, failed));
|
|
Emit(target, CultMeshProtocols.RealtimeState, "online", result.Candidate.Endpoint);
|
|
return session;
|
|
}
|
|
|
|
private async Task<ConnectedRealtimePath> ResolveRealtimePathAsync(CultMeshSessionTarget target)
|
|
{
|
|
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(
|
|
target.AuthorityRuntimeId,
|
|
new[] { target.VerseId },
|
|
target.AuthorityRuntimeId)).ConfigureAwait(false);
|
|
var routes = BoundCandidates(discovery, target, CultMeshProtocols.RealtimeState)
|
|
.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 '{target}'.");
|
|
|
|
var failures = new List<Exception>();
|
|
foreach (var tier in routes.GroupBy(route => route.Connector.Priority).OrderBy(group => group.Key))
|
|
{
|
|
var attempts = tier.Take(_options.MaxRacedCandidates)
|
|
.Select(route => ConnectRealtimeCandidateAsync(target, 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();
|
|
var reason = last is CultMeshSessionException typed
|
|
? typed.Failure.Reason
|
|
: last is TimeoutException
|
|
? CultMeshSessionFailureReason.Timeout
|
|
: CultMeshSessionFailureReason.Transport;
|
|
throw Failure(
|
|
reason,
|
|
$"No realtime state path connected for '{target}'.",
|
|
last);
|
|
}
|
|
|
|
private static async Task<ConnectedRealtimePath> ConnectRealtimeCandidateAsync(
|
|
CultMeshSessionTarget target,
|
|
RealtimeRoute route)
|
|
{
|
|
var transport = await route.Connector.ConnectAsync(route.Candidate, target).ConfigureAwait(false);
|
|
try
|
|
{
|
|
CultMeshTransportIdentity.RequireVerified(
|
|
transport, target, CultMeshProtocols.RealtimeState, route.Candidate);
|
|
return new ConnectedRealtimePath(route.Candidate, transport);
|
|
}
|
|
catch
|
|
{
|
|
transport.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
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.Target, CultMeshProtocols.RealtimeState, "offline", endpoint);
|
|
}
|
|
|
|
private async Task<CultMeshSession> ConnectOwnedAsync(string key, CultMeshSessionTarget target, CultMeshProtocolId protocol)
|
|
{
|
|
var result = await ResolveConnectedPathAsync(target, protocol).ConfigureAwait(false);
|
|
var session = new CultMeshSession(target, protocol, result.Client,
|
|
new CultMeshSessionState(CultMeshSessionStatus.Online, _options.Clock.UtcNow, result.Candidate));
|
|
ObservePhysicalFailure(key, session, result);
|
|
Emit(target, protocol, "online", result.Candidate.Endpoint);
|
|
return session;
|
|
}
|
|
|
|
private async Task<ConnectedPath> ResolveConnectedPathAsync(CultMeshSessionTarget target, CultMeshProtocolId protocol)
|
|
{
|
|
var discovery = await _discovery.ResolveAsync(new CultMeshDiscoveryQuery(
|
|
target.AuthorityRuntimeId,
|
|
new[] { target.VerseId },
|
|
target.AuthorityRuntimeId)).ConfigureAwait(false);
|
|
var candidates = BoundCandidates(discovery, target, protocol);
|
|
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 '{target}'.");
|
|
if (routes.Length == 0)
|
|
throw Failure(
|
|
CultMeshSessionFailureReason.Transport,
|
|
$"No transport path connected for '{target}' and protocol '{protocol}'.",
|
|
Failure(
|
|
CultMeshSessionFailureReason.UnsupportedPath,
|
|
$"No registered connector supports an advertised route for '{target}'."));
|
|
|
|
var failures = new List<Exception>();
|
|
foreach (var tier in routes.GroupBy(route => route.Connector.Priority).OrderBy(group => group.Key))
|
|
{
|
|
var attempts = tier.Take(_options.MaxRacedCandidates)
|
|
.Select(route => ConnectCandidateAsync(target, route, protocol))
|
|
.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.Client.Dispose(); }, TaskScheduler.Default);
|
|
return result;
|
|
}
|
|
catch (Exception error) { failures.Add(error); }
|
|
}
|
|
}
|
|
var last = failures.LastOrDefault();
|
|
var reason = last is CultMeshSessionException typed
|
|
? typed.Failure.Reason
|
|
: last is TimeoutException
|
|
? CultMeshSessionFailureReason.Timeout
|
|
: CultMeshSessionFailureReason.Transport;
|
|
throw Failure(reason,
|
|
$"No transport path connected for '{target}' and protocol '{protocol}'.", last);
|
|
}
|
|
|
|
private void ObservePhysicalFailure(string key, CultMeshSession session, ConnectedPath path)
|
|
{
|
|
if (path.Client is not ICultNetSchemaClientHealth health) return;
|
|
_ = health.BackgroundFailure.ContinueWith(
|
|
task => ReconnectOwnedAsync(key, session, path, task),
|
|
CancellationToken.None,
|
|
TaskContinuationOptions.ExecuteSynchronously,
|
|
TaskScheduler.Default).Unwrap();
|
|
}
|
|
|
|
private async Task ReconnectOwnedAsync(
|
|
string key,
|
|
CultMeshSession session,
|
|
ConnectedPath failedPath,
|
|
Task<Exception> failureTask)
|
|
{
|
|
if (_disposed || session.State.Status == CultMeshSessionStatus.Offline) return;
|
|
var error = failureTask.Status == TaskStatus.RanToCompletion ? failureTask.Result : failureTask.Exception;
|
|
var failure = new CultMeshSessionFailure(
|
|
CultMeshSessionFailureReason.Transport,
|
|
error?.Message ?? "Transport background loop stopped.",
|
|
failedPath.Candidate.Endpoint);
|
|
session.Transition(new CultMeshSessionState(
|
|
CultMeshSessionStatus.Reconnecting,
|
|
_options.Clock.UtcNow,
|
|
failedPath.Candidate,
|
|
failure));
|
|
Emit(session.Target, session.Protocol, "reconnecting", failedPath.Candidate.Endpoint);
|
|
try
|
|
{
|
|
_discovery.Invalidate(new CultMeshDiscoveryQuery(
|
|
session.Target.AuthorityRuntimeId,
|
|
new[] { session.Target.VerseId },
|
|
session.Target.AuthorityRuntimeId));
|
|
var replacement = await ResolveConnectedPathAsync(session.Target, session.Protocol).ConfigureAwait(false);
|
|
session.ReplacePhysicalChannel(replacement.Client);
|
|
session.Transition(new CultMeshSessionState(
|
|
CultMeshSessionStatus.Online,
|
|
_options.Clock.UtcNow,
|
|
replacement.Candidate));
|
|
ObservePhysicalFailure(key, session, replacement);
|
|
Emit(session.Target, session.Protocol, "online", replacement.Candidate.Endpoint);
|
|
}
|
|
catch (Exception reconnectError)
|
|
{
|
|
session.Transition(new CultMeshSessionState(
|
|
CultMeshSessionStatus.Offline,
|
|
_options.Clock.UtcNow,
|
|
failedPath.Candidate,
|
|
new CultMeshSessionFailure(
|
|
reconnectError is CultMeshSessionException typed ? typed.Failure.Reason : CultMeshSessionFailureReason.Transport,
|
|
reconnectError.Message,
|
|
failedPath.Candidate.Endpoint)));
|
|
_sessions.TryRemove(key, out _);
|
|
Emit(session.Target, session.Protocol, "offline", failedPath.Candidate.Endpoint);
|
|
}
|
|
}
|
|
|
|
private async Task<ConnectedPath> ConnectCandidateAsync(
|
|
CultMeshSessionTarget target,
|
|
SchemaRoute route,
|
|
CultMeshProtocolId protocol)
|
|
{
|
|
var client = await route.Connector.ConnectAsync(route.Candidate, protocol).ConfigureAwait(false);
|
|
try
|
|
{
|
|
await CultMeshSessionIdentityClient.VerifyAsync(
|
|
client,
|
|
_options.RuntimeId,
|
|
target,
|
|
protocol,
|
|
route.Candidate,
|
|
_options.Trust,
|
|
_options.Clock.UtcNow,
|
|
_options.IdentityHandshakeTimeout).ConfigureAwait(false);
|
|
return new ConnectedPath(route.Candidate, client);
|
|
}
|
|
catch
|
|
{
|
|
client.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static async Task<T> AwaitForCallerAsync<T>(Task<T> shared, CancellationToken cancellationToken)
|
|
{
|
|
if (!cancellationToken.CanBeCanceled) return await shared.ConfigureAwait(false);
|
|
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
using (cancellationToken.Register(() => cancelled.TrySetResult(true)))
|
|
{
|
|
if (await Task.WhenAny(shared, cancelled.Task).ConfigureAwait(false) != shared)
|
|
throw new OperationCanceledException(cancellationToken);
|
|
}
|
|
return await shared.ConfigureAwait(false);
|
|
}
|
|
|
|
private CultMeshSessionException Failure(CultMeshSessionFailureReason reason, string message, Exception? inner = null) =>
|
|
new CultMeshSessionException(new CultMeshSessionFailure(reason, message), inner);
|
|
|
|
private CultMeshTransportCandidate[] BoundCandidates(
|
|
CultMeshDiscoveryState discovery,
|
|
CultMeshSessionTarget target,
|
|
CultMeshProtocolId protocol)
|
|
{
|
|
var routes = discovery.Candidates
|
|
.SelectMany(candidate => candidate.Descriptor.AuthorityRoutes)
|
|
.Where(route => string.Equals(
|
|
route.AuthorityRuntimeId,
|
|
target.AuthorityRuntimeId,
|
|
StringComparison.Ordinal))
|
|
.Where(route => route.Supports(protocol))
|
|
.ToArray();
|
|
var verified = new List<CultMeshTransportCandidate>();
|
|
var rejected = new List<Exception>();
|
|
foreach (var route in routes)
|
|
{
|
|
try
|
|
{
|
|
verified.Add(new CultMeshTransportCandidate(
|
|
route.Endpoint,
|
|
priority: route.Priority,
|
|
authorityRuntimeId: route.AuthorityRuntimeId,
|
|
generation: route.Generation,
|
|
authorityRoute: route)
|
|
.WithVerifiedAuthority(_options.Trust.Verify(
|
|
target.VerseId,
|
|
route,
|
|
_options.Clock.UtcNow)));
|
|
}
|
|
catch (CultMeshSessionException error)
|
|
{
|
|
rejected.Add(error);
|
|
}
|
|
}
|
|
if (verified.Count == 0 && rejected.Count > 0)
|
|
{
|
|
throw Failure(
|
|
CultMeshSessionFailureReason.Authentication,
|
|
$"No trusted route remained for CultMesh target '{target}' and protocol '{protocol}'.",
|
|
new AggregateException(rejected));
|
|
}
|
|
return verified
|
|
.GroupBy(candidate => candidate.Endpoint, StringComparer.Ordinal)
|
|
.Select(group => group.OrderBy(candidate => candidate.Priority).First())
|
|
.OrderBy(candidate => candidate.Priority)
|
|
.ThenBy(candidate => candidate.Endpoint, StringComparer.Ordinal)
|
|
.ToArray();
|
|
}
|
|
|
|
private void Emit(CultMeshSessionTarget target, CultMeshProtocolId protocol, string state, string path)
|
|
{
|
|
_options.Diagnostics.Emit(new CultMeshDiagnosticEvent(
|
|
Interlocked.Increment(ref _diagnosticSequence), _options.Clock.UtcNow,
|
|
CultMeshReliabilityOrgan.Session, CultMeshDiagnosticKind.PathChanged,
|
|
target.SessionKey + ":" + protocol.Value, target.AuthorityRuntimeId, state,
|
|
sourceId: "session-manager", endpoint: path));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
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)); }
|
|
|
|
private sealed class ConnectedPath
|
|
{
|
|
public ConnectedPath(CultMeshTransportCandidate candidate, ICultNetSchemaClient client) { Candidate = candidate; Client = client; }
|
|
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; }
|
|
}
|
|
}
|
|
}
|