Retain native clients across provider routes

This commit is contained in:
Meta Vens 2026-08-18 03:34:59 +02:00
commit cb7f9e8fa2
16 changed files with 317 additions and 160 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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(

View file

@ -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

View file

@ -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:

View file

@ -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).

View file

@ -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();

View file

@ -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>