Retain native clients across provider routes
This commit is contained in:
parent
3c19e54d91
commit
cb7f9e8fa2
16 changed files with 317 additions and 160 deletions
|
|
@ -782,7 +782,7 @@ namespace GameCult.Mesh
|
|||
new CultMeshVerseCatalogRequestMessage
|
||||
{
|
||||
VerseIds = query.VerseIds.Count == 0 ? null : query.VerseIds.ToArray(),
|
||||
TransportVersion = "cultmesh.v0"
|
||||
TransportVersion = _options.TransportVersion
|
||||
}).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var observedAt = _options.Clock.UtcNow;
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ namespace GameCult.Mesh
|
|||
private readonly CultMeshDiscoveryServiceOptions _options;
|
||||
private readonly ConcurrentDictionary<string, Lazy<Task<CultMeshDiscoveryState>>> _inFlight = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, CultMeshDiscoveryState> _states = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, byte> _invalidated = new(StringComparer.Ordinal);
|
||||
private readonly Subject<CultMeshDiscoveryState> _updates = new();
|
||||
private long _diagnosticSequence;
|
||||
private bool _disposed;
|
||||
|
|
@ -183,6 +184,19 @@ namespace GameCult.Mesh
|
|||
return state == null ? null : EvaluateAt(state, _options.Clock.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates one resolved identity so a transport failure must consult the
|
||||
/// configured rendezvous sources again instead of retrying a fresh-but-dead route.
|
||||
/// </summary>
|
||||
public void Invalidate(CultMeshDiscoveryQuery query)
|
||||
{
|
||||
if (query == null) throw new ArgumentNullException(nameof(query));
|
||||
ThrowIfDisposed();
|
||||
var key = QueryKey(query);
|
||||
_states.TryRemove(key, out _);
|
||||
_invalidated[key] = 0;
|
||||
}
|
||||
|
||||
public async Task<CultMeshDiscoveryState> ResolveAsync(
|
||||
CultMeshDiscoveryQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
|
@ -315,6 +329,7 @@ namespace GameCult.Mesh
|
|||
private async Task<CultMeshDiscoveryState?> LoadPreviousAsync(string queryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_states.TryGetValue(queryKey, out var current)) return current;
|
||||
if (_invalidated.TryRemove(queryKey, out _)) return null;
|
||||
if (_options.Store == null) return null;
|
||||
var stored = await _options.Store.LoadAsync(queryKey, cancellationToken).ConfigureAwait(false);
|
||||
if (stored != null) _states[queryKey] = stored;
|
||||
|
|
|
|||
|
|
@ -110,6 +110,66 @@ namespace GameCult.Mesh
|
|||
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.
|
||||
|
|
@ -862,6 +922,9 @@ namespace GameCult.Mesh
|
|||
Emit(session.EndpointId, session.Protocol, "reconnecting", failedPath.Candidate.Endpoint);
|
||||
try
|
||||
{
|
||||
_discovery.Invalidate(new CultMeshDiscoveryQuery(
|
||||
session.EndpointId.Value,
|
||||
new[] { session.EndpointId.Value }));
|
||||
var replacement = await ResolveConnectedPathAsync(session.EndpointId, session.Protocol).ConfigureAwait(false);
|
||||
session.ReplacePhysicalChannel(replacement.Client);
|
||||
session.Transition(new CultMeshSessionState(
|
||||
|
|
|
|||
|
|
@ -182,6 +182,9 @@ namespace GameCult.Mesh
|
|||
/// <summary>Gets or sets the identity of this bootstrap lookup source.</summary>
|
||||
public string SourceId { get; set; } = "configured-bootstrap";
|
||||
|
||||
/// <summary>Gets or sets the CultMesh transport contract requested from rendezvous.</summary>
|
||||
public string TransportVersion { get; set; } = "cultmesh.v0";
|
||||
|
||||
/// <summary>Gets or sets how long a successful compatibility observation remains fresh.</summary>
|
||||
public TimeSpan ObservationTtl { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
|
|
@ -215,7 +218,6 @@ namespace GameCult.Mesh
|
|||
CultMeshVerseCatalogRequestMessage? request = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value must be non-empty.", nameof(endpoint));
|
||||
var (host, port) = CultNetSchemaWriteForwarder.ParseEndpoint(endpoint);
|
||||
var messageId = Guid.NewGuid().ToString("N");
|
||||
var completion = new TaskCompletionSource<CultMeshVerseCatalogResponseMessage>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
|
@ -234,7 +236,13 @@ namespace GameCult.Mesh
|
|||
Emit(CultMeshDiagnosticKind.ConnectionAttempt, messageId, endpoint, "connecting");
|
||||
try
|
||||
{
|
||||
client.Connect(host, port);
|
||||
if (client is ICultNetUriSchemaClient uriClient)
|
||||
await uriClient.ConnectAsync(new Uri(endpoint)).ConfigureAwait(false);
|
||||
else
|
||||
{
|
||||
var (host, port) = CultNetSchemaWriteForwarder.ParseEndpoint(endpoint);
|
||||
client.Connect(host, port);
|
||||
}
|
||||
var backgroundFailure = (client as ICultNetSchemaClientHealth)?.BackgroundFailure;
|
||||
await WaitForConnectionAsync(client, endpoint, backgroundFailure).ConfigureAwait(false);
|
||||
client.SendCultNet(new CultMeshVerseCatalogRequestMessage
|
||||
|
|
|
|||
|
|
@ -172,18 +172,21 @@ Current executable coverage from `scripts/verify-eve-browser-network.mjs`:
|
|||
|
||||
- authenticated binary C# WebSocket provider;
|
||||
- separate local Odin fixture using the canonical Verse-catalog messages;
|
||||
- real Chromium and independent C# headless consumers;
|
||||
- real Chromium and retained C# headless consumers, both discovered through
|
||||
the same Odin identity;
|
||||
- shared surface and counter document leases;
|
||||
- Eve button to typed operation to provider receipt to shared state;
|
||||
- duplicate idempotency key produces one effect;
|
||||
- provider restart on a different physical port rehydrates the `.cc` state;
|
||||
- the retained browser lease rediscovers through Odin, resubscribes, and
|
||||
commits a second provider-authored receipt;
|
||||
- the retained C# lease independently rediscovers, resubscribes, and observes
|
||||
both canonical receipt ids without application reconnect code;
|
||||
- generated browser bundle contains no `node:` builtin import.
|
||||
|
||||
The full public Verse gate still requires the deployed Odin daemon rather than
|
||||
the local contract fixture, retained C# lease reconnection through the same
|
||||
identity path, released-artifact-only .NET consumption, and Windows/Linux CI.
|
||||
the local contract fixture and released-artifact-only .NET consumption. The
|
||||
source-contract gate runs on Windows and Linux CI.
|
||||
|
||||
From an empty temporary consumer using packed/released artifacts only:
|
||||
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ does **not** prove the network layer; the second program owns that evidence.
|
|||
The network program is
|
||||
[`samples/eve-browser-network`](../../../../samples/eve-browser-network/README.md).
|
||||
Run it separately only while diagnosing that layer. A smoke against the
|
||||
deployed Odin daemon and retained C# lease reconnection remain separate
|
||||
infrastructure gates. Their owner map and full acceptance contract are recorded in
|
||||
deployed Odin daemon remains a separate infrastructure gate. Its owner map
|
||||
and full acceptance contract are recorded in
|
||||
[`browser-verse-transport.md`](../browser-verse-transport.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ using GameCult.Networking;
|
|||
namespace GameCult.Networking.WebSockets;
|
||||
|
||||
/// <summary>Schema-v0 client over a binary WebSocket route.</summary>
|
||||
public sealed class CultNetWebSocketSchemaClient : ICultNetSchemaClient, ICultNetSchemaClientHealth
|
||||
public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICultNetSchemaClientHealth
|
||||
{
|
||||
private readonly ConcurrentDictionary<Type, List<Delegate>> _handlers = new();
|
||||
private readonly object _handlerGate = new();
|
||||
|
|
|
|||
|
|
@ -38,6 +38,17 @@ namespace GameCult.Networking
|
|||
where T : ICultNetSchemaMessage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schema client that connects to one exact advertised URI, including its path.
|
||||
/// Host/port-only connection is retained for legacy transports; rendezvous and
|
||||
/// route-aware clients should prefer this contract when available.
|
||||
/// </summary>
|
||||
public interface ICultNetUriSchemaClient : ICultNetSchemaClient
|
||||
{
|
||||
/// <summary>Connects to the exact advertised schema endpoint.</summary>
|
||||
Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exposes terminal failure of a schema client's background transport work.
|
||||
/// </summary>
|
||||
|
|
|
|||
Loading…
Reference in a new issue