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

@ -3,9 +3,12 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EveRoot Condition="'$(EveRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\Eve</EveRoot>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj" />
<ProjectReference Include="..\..\src\GameCult.Mesh\GameCult.Mesh.csproj" />
<ProjectReference Include="$(EveRoot)\packages\org.gamecult.eve.surface\GameCult.Eve.Surface.csproj"
AdditionalProperties="CultLibRoot=$(MSBuildThisFileDirectory)..\.." />
</ItemGroup>
</Project>

View file

@ -2,6 +2,7 @@ using System.Net;
using System.Text.Json;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using GameCult.Eve.Surface;
using GameCult.Mesh;
using GameCult.Networking;
using GameCult.Networking.WebSockets;
@ -70,7 +71,7 @@ static async Task RunOdinAsync(Args arguments)
static async Task RunProviderAsync(Args arguments)
{
var registry = CultDocumentRegistry.ForTypes([typeof(CounterState), typeof(CounterSurfaceDocument)]);
var registry = CultDocumentRegistry.ForTypes([typeof(CounterState), typeof(EveSurfaceDocument)]);
using var cache = await CultCacheMessagePack.OpenAsync(arguments.StatePath, new CultCacheOpenOptions
{
Registry = registry,
@ -79,7 +80,7 @@ static async Task RunProviderAsync(Args arguments)
});
var documents = new CultNetDocumentRegistry(registry)
.Register(CultNetDocumentBinding.ForDocument<CounterState>(registry, "sample.counter_state.v1"))
.Register(CultNetDocumentBinding.ForDocument<CounterSurfaceDocument>(registry, "gamecult.eve.surface.v1"));
.Register(CultNetDocumentBinding.ForDocument<EveSurfaceDocument>(registry, EveSurfaceDocument.SchemaId));
using var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
RuntimeId = "sample.counter-provider",
@ -87,8 +88,8 @@ static async Task RunProviderAsync(Args arguments)
});
if (cache.Get<CounterState>(new CultRecordKey(counterKey)) == null)
await database.PutAsync(new CultRecordKey(counterKey), CounterState.Initial(counterKey));
if (cache.Get<CounterSurfaceDocument>(new CultRecordKey(surfaceKey)) == null)
await database.PutAsync(new CultRecordKey(surfaceKey), CounterSurfaceDocument.Create());
if (cache.Get<EveSurfaceDocument>(new CultRecordKey(surfaceKey)) == null)
await database.PutAsync(new CultRecordKey(surfaceKey), CreateCounterSurface());
await cache.FlushAsync();
await using var schemaServer = new CultNetWebSocketSchemaServer();
@ -160,28 +161,107 @@ static async Task RunProviderAsync(Args arguments)
static async Task RunHeadlessAsync(Args arguments)
{
var registry = CultDocumentRegistry.ForTypes([typeof(CounterState)]);
using var cache = new CultCache(registry);
var documents = new CultNetDocumentRegistry(registry)
.Register(CultNetDocumentBinding.ForDocument<CounterState>(registry, "sample.counter_state.v1"));
using var transport = new CultNetWebSocketSchemaClient(options =>
if (string.IsNullOrWhiteSpace(arguments.OdinEndpoint))
throw new ArgumentException("The headless client requires --odin.");
CultNetWebSocketSchemaClient CreateClient() => new(options =>
options.SetRequestHeader("Cookie", $"cultnet_session={arguments.Token}"));
using var subscriptions = new CultNetDatabaseSubscriptionClient(transport, cache, documents);
var changed = new TaskCompletionSource<CounterState>(TaskCreationOptions.RunContinuationsAsynchronously);
subscriptions.Changed += change =>
using var mesh = new CultMeshClient(new CultMeshClientOptions
{
if (change.Document is CounterState counter) changed.TrySetResult(counter);
};
await transport.ConnectAsync(new Uri(arguments.Endpoint));
var initial = await subscriptions.SubscribeAsync(
"headless-counter",
recordKeys: [counterKey],
schemaIds: ["sample.counter_state.v1"],
deliveryMode: CultNetDatabaseSubscriptionDeliveryMode.Live);
var counter = initial.OfType<CounterState>().Single();
Console.WriteLine("HEADLESS_READY " + JsonSerializer.Serialize(new { count = counter.Count }));
var update = await changed.Task.WaitAsync(TimeSpan.FromSeconds(15));
Console.WriteLine("HEADLESS_UPDATE " + JsonSerializer.Serialize(new { count = update.Count }));
RendezvousEndpoints = [arguments.OdinEndpoint],
Discovery = new CultMeshVerseDiscoveryClientOptions
{
CreateClient = CreateClient,
TransportVersion = arguments.TransportVersion
},
Connectors =
[
new CultMeshUriSchemaTransportConnector(
"cultnet-websocket",
["ws", "wss"],
_ => CreateClient())
]
});
using var lease = await mesh.LeaseDocumentAsync<CounterState>(arguments.VerseId, counterKey);
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var lastCount = -1;
using var watch = lease.Handle.Watch(counter =>
{
if (counter.Count <= lastCount) return;
lastCount = counter.Count;
if (counter.Count == 0)
Console.WriteLine("HEADLESS_READY " + JsonSerializer.Serialize(new { count = counter.Count }));
else
Console.WriteLine($"HEADLESS_UPDATE_{counter.Count} " + JsonSerializer.Serialize(new
{
count = counter.Count,
receiptIds = counter.Receipts.Values
.Select(receipt => receipt.ReceiptId)
.OrderBy(value => value, StringComparer.Ordinal)
.ToArray()
}));
if (counter.Count >= arguments.ExpectedCount) completion.TrySetResult();
});
var initial = await lease.Handle.LatestAsync();
if (lastCount < 0)
{
lastCount = initial.Count;
Console.WriteLine("HEADLESS_READY " + JsonSerializer.Serialize(new { count = initial.Count }));
}
if (initial.Count >= arguments.ExpectedCount) completion.TrySetResult();
await completion.Task.WaitAsync(TimeSpan.FromSeconds(45));
}
static EveSurfaceDocument CreateCounterSurface()
{
var networkRoute = new CultMeshRouteHint(CultMeshLocalityKind.Network, "cultmesh");
return new EveSurfaceDocument(
providerId: "sample.counter-provider",
providerKind: "sample.daemon",
title: "CultMesh browser counter",
version: 1,
updatedAtUtc: "2026-08-17T00:00:00Z",
surface: new EveSurfaceTree(
surfaceKey,
new EveSurfaceComponent(
"counter.root",
"column",
new Dictionary<string, string>(StringComparer.Ordinal),
[
new EveSurfaceComponent(
"counter.value",
"metric",
new Dictionary<string, string>(StringComparer.Ordinal)
{
["label"] = "Canonical count",
["value"] = "0"
},
[],
[
new CultMeshStateBindingDescriptor(
"value",
"sample.counter.count",
"sample.counter_state:counter:main",
"sample.counter_state.v1",
networkRoute)
]),
new EveSurfaceComponent(
"counter.increment",
"control.button",
new Dictionary<string, string>(StringComparer.Ordinal)
{
["label"] = "Increment",
["command"] = "sample.counter.increment"
},
[])
]),
[]),
commands:
[
new EveCommandTemplate(new CultMeshOperationBindingDescriptor(
"sample.counter.increment",
"Increment",
routeHint: networkRoute))
]);
}
sealed class Args
@ -189,14 +269,15 @@ sealed class Args
public string Mode { get; private init; } = "";
public int Port { get; private init; }
public string StatePath { get; private init; } = "sample-counter.cc";
public string Endpoint { get; private init; } = "";
public string OdinEndpoint { get; private init; } = "";
public string ProviderEndpoint { get; private init; } = "";
public string Token { get; private init; } = "sample-session";
public string VerseId { get; private init; } = "sample.counter";
public string VerseName { get; private init; } = "CultMesh browser counter";
public string AuthorityRuntimeId { get; private init; } = "sample.counter-provider";
public string TransportVersion { get; private init; } = "cultmesh.v1";
public string TransportVersion { get; private init; } = "cultmesh.v0";
public string RulesHash { get; private init; } = "sample-counter-v1";
public int ExpectedCount { get; private init; } = 2;
public static Args Parse(string[] values)
{
@ -209,14 +290,17 @@ sealed class Args
Mode = values[0],
Port = named.TryGetValue("--port", out var port) ? int.Parse(port) : 0,
StatePath = named.GetValueOrDefault("--state", "sample-counter.cc"),
Endpoint = named.GetValueOrDefault("--endpoint", ""),
OdinEndpoint = named.GetValueOrDefault("--odin", ""),
ProviderEndpoint = named.GetValueOrDefault("--provider-endpoint", ""),
Token = named.GetValueOrDefault("--token", "sample-session"),
VerseId = named.GetValueOrDefault("--verse-id", "sample.counter"),
VerseName = named.GetValueOrDefault("--verse-name", "CultMesh browser counter"),
AuthorityRuntimeId = named.GetValueOrDefault("--authority-runtime-id", "sample.counter-provider"),
TransportVersion = named.GetValueOrDefault("--transport-version", "cultmesh.v1"),
RulesHash = named.GetValueOrDefault("--rules-hash", "sample-counter-v1")
TransportVersion = named.GetValueOrDefault("--transport-version", "cultmesh.v0"),
RulesHash = named.GetValueOrDefault("--rules-hash", "sample-counter-v1"),
ExpectedCount = named.TryGetValue("--expected-count", out var expectedCount)
? int.Parse(expectedCount)
: 2
};
}
}
@ -264,112 +348,3 @@ public sealed class IncrementReceipt
Count = count
};
}
[CultDocument("gamecult.eve.surface", "gamecult.eve.surface.v1")]
[MessagePackObject]
public sealed class CounterSurfaceDocument
{
[Key("type")] public string Type { get; set; } = "surface-state";
[Key("schema")] public string Schema { get; set; } = "gamecult.eve.surface.v1";
[Key("providerId")] public string ProviderId { get; set; } = "sample.counter-provider";
[Key("providerKind")] public string ProviderKind { get; set; } = "sample.daemon";
[Key("title")] public string Title { get; set; } = "CultMesh browser counter";
[Key("version")] public int Version { get; set; } = 1;
[Key("updatedAtUtc")] public string UpdatedAtUtc { get; set; } = "2026-08-17T00:00:00Z";
[Key("surface")] public EveSurface Surface { get; set; } = new();
[Key("commands")] public EveCommand[] Commands { get; set; } = [];
public static CounterSurfaceDocument Create() => new()
{
Surface = new EveSurface
{
Id = "sample.counter",
Title = "Counter",
Root = new EveComponent
{
Id = "counter.root",
Kind = "column",
Children =
[
new EveComponent
{
Id = "counter.value",
Kind = "metric",
Props = new Dictionary<string, object> { ["label"] = "Canonical count", ["value"] = 0 },
StateBindings =
[
new EveStateBinding
{
TargetProp = "value",
PointerId = "sample.counter.count",
SourceId = "sample.counter_state:counter:main",
SchemaId = "sample.counter_state.v1",
RouteKind = "cultmesh"
}
]
},
new EveComponent
{
Id = "counter.increment",
Kind = "control.button",
Props = new Dictionary<string, object>
{
["label"] = "Increment",
["command"] = "sample.counter.increment"
}
}
]
}
},
Commands =
[
new EveCommand
{
Command = "sample.counter.increment",
Label = "Increment",
SurfaceId = "sample.counter"
}
]
};
}
[MessagePackObject]
public sealed class EveSurface
{
[Key("id")] public string Id { get; set; } = "";
[Key("title")] public string Title { get; set; } = "";
[Key("root")] public EveComponent Root { get; set; } = new();
[Key("styles")] public Dictionary<string, object> Styles { get; set; } = [];
}
[MessagePackObject]
public sealed class EveComponent
{
[Key("id")] public string Id { get; set; } = "";
[Key("kind")] public string Kind { get; set; } = "";
[Key("props")] public Dictionary<string, object> Props { get; set; } = [];
[Key("children")] public EveComponent[] Children { get; set; } = [];
[Key("stateBindings")] public EveStateBinding[] StateBindings { get; set; } = [];
}
[MessagePackObject]
public sealed class EveStateBinding
{
[Key("targetProp")] public string TargetProp { get; set; } = "";
[Key("pointerId")] public string PointerId { get; set; } = "";
[Key("sourceId")] public string SourceId { get; set; } = "";
[Key("schemaId")] public string SchemaId { get; set; } = "";
[Key("routeKind")] public string RouteKind { get; set; } = "";
}
[MessagePackObject]
public sealed class EveCommand
{
[Key("schema")] public string Schema { get; set; } = "gamecult.eve.command.v1";
[Key("command")] public string Command { get; set; } = "";
[Key("label")] public string Label { get; set; } = "";
[Key("surfaceId")] public string SurfaceId { get; set; } = "";
[Key("transport")] public string Transport { get; set; } = "cultmesh";
[Key("authority")] public string Authority { get; set; } = "provider-daemon";
[Key("result")] public string Result { get; set; } = "gamecult.eve.command_receipt.v1";
}

View file

@ -6,7 +6,9 @@ This is the real-process continuation of `samples/eve-two-runtime`:
idempotency, and canonical receipts;
- one real Chromium page leases both documents through `cultmesh-browser` and
lowers the provider-owned Eve surface;
- one independent C# headless client observes the same typed counter;
- one retained C# headless client discovers the provider through the same Odin
identity, observes the typed counter and canonical receipt ids, and survives
the provider route replacement without application reconnect code;
- one separate local Odin fixture answers the canonical CultMesh Verse catalog
over binary CultNet WebSocket messages;
- the browser and C# clients speak the same binary CultNet schema-v0 WebSocket
@ -28,6 +30,8 @@ The verifier clicks the Eve button twice with one idempotency key, requires one
state transition, restarts the provider on a different physical port, updates
the local Odin fixture, and requires the still-open browser to rediscover the
route, resubscribe, execute a second command, and preserve the durable count.
The retained C# lease independently rediscovers the same replacement route and
observes both canonical receipt ids.
The fixture speaks the production Verse-catalog contract; a deployment smoke
against the full Odin daemon remains a separate infrastructure gate.

View file

@ -35,7 +35,7 @@ try {
rendezvous: new CultMeshBrowserOdinRendezvous({
endpoints: [odinEndpoint],
runtimeId: "sample.chromium.discovery",
transportVersion: "cultmesh.v1",
transportVersion: "cultmesh.v0",
}),
});
window.__sampleConnectionStates = [];

View file

@ -20,6 +20,7 @@ const statePath = join(workRoot, "counter.cc");
const bundlePath = join(workRoot, "bundle.js");
const token = "sample-session";
let provider;
let replacementProvider;
let odin;
let headless;
let httpServer;
@ -51,6 +52,7 @@ try {
join(sampleRoot, "EveBrowserNetworkSample.csproj"),
"--verbosity", "quiet",
"-p:NoWarn=1591%3BCS8632",
`-p:EveRoot=${eveRoot}`,
]);
const providerPort = await freePort();
const replacementProviderPort = await freePort();
@ -60,7 +62,10 @@ try {
const endpoint = await provider.waitFor("PROVIDER_READY ");
odin = await startOdin(odinPort, endpoint);
const odinEndpoint = await odin.waitFor("ODIN_READY ");
headless = startDotnet(["headless", "--endpoint", endpoint, "--token", token]);
headless = startDotnet([
"headless", "--odin", odinEndpoint, "--verse-id", "sample.counter",
"--expected-count", "2", "--token", token,
]);
await headless.waitFor("HEADLESS_READY ");
httpServer = await serve(httpPort, sampleRoot, bundlePath);
@ -74,24 +79,33 @@ try {
await page.locator("button").click();
await page.waitForFunction(() => window.__sampleReceipt?.count === 1 && window.__sampleCount === 1);
const firstReceipt = await page.evaluate(() => window.__sampleReceipt?.receiptId);
assert.equal(JSON.parse(await headless.waitFor("HEADLESS_UPDATE ")).count, 1);
const firstHeadlessUpdate = JSON.parse(await headless.waitFor("HEADLESS_UPDATE_1 "));
assert.equal(firstHeadlessUpdate.count, 1);
assert.ok(firstHeadlessUpdate.receiptIds.includes(firstReceipt));
await page.locator("button").click();
await page.waitForFunction(receipt => window.__sampleReceipt?.receiptId === receipt, firstReceipt);
assert.equal(await page.evaluate(() => window.__sampleCount), 1, "duplicate idempotency key changed canonical state");
await stop(provider.process);
await stop(odin.process);
provider = await startProvider(replacementProviderPort);
const restartedEndpoint = await provider.waitFor("PROVIDER_READY ");
replacementProvider = await startProvider(replacementProviderPort);
const restartedEndpoint = await replacementProvider.waitFor("PROVIDER_READY ");
assert.notEqual(restartedEndpoint, endpoint);
await stop(odin.process);
odin = await startOdin(odinPort, restartedEndpoint);
assert.equal(await odin.waitFor("ODIN_READY "), odinEndpoint);
headless = startDotnet(["headless", "--endpoint", restartedEndpoint, "--token", token]);
await headless.waitFor("HEADLESS_READY ");
await stop(provider.process);
provider = replacementProvider;
replacementProvider = undefined;
await page.waitForFunction(() =>
window.__sampleConnectionStates?.includes("reconnecting")
&& window.__sampleConnectionStates.at(-1) === "connected");
await page.evaluate(() => { window.__sampleCommandId = "browser-click-2"; });
await page.locator("button").click();
await page.waitForFunction(() => window.__sampleReceipt?.count === 2 && window.__sampleCount === 2);
assert.equal(JSON.parse(await headless.waitFor("HEADLESS_UPDATE ")).count, 2);
const secondReceipt = await page.evaluate(() => window.__sampleReceipt?.receiptId);
const secondHeadlessUpdate = JSON.parse(await headless.waitFor("HEADLESS_UPDATE_2 "));
assert.equal(secondHeadlessUpdate.count, 2);
assert.ok(secondHeadlessUpdate.receiptIds.includes(firstReceipt));
assert.ok(secondHeadlessUpdate.receiptIds.includes(secondReceipt));
const connectionStates = await page.evaluate(() => window.__sampleConnectionStates);
assert.equal(connectionStates[0], "connected");
assert.equal(connectionStates.at(-1), "connected");
@ -105,13 +119,16 @@ try {
browserRuntime: "chromium",
headlessRuntime: "csharp",
receiptId: firstReceipt,
secondReceiptId: secondReceipt,
count: 2,
routeRotationCount: 1,
retainedHeadlessLease: true,
}));
} finally {
if (browser) await browser.close().catch(() => undefined);
if (headless) await stop(headless.process);
if (provider) await stop(provider.process);
if (replacementProvider) await stop(replacementProvider.process);
if (odin) await stop(odin.process);
if (httpServer) await new Promise(resolve => httpServer.close(resolve));
await rm(workRoot, { recursive: true, force: true });

View file

@ -131,7 +131,7 @@ try {
throw "Clean consumer package installation failed."
}
Copy-Item -LiteralPath $samplePath -Destination (Join-Path $consumerRoot "sample.mjs")
& $nodePath .\sample.mjs
& $nodePath (Join-Path $consumerRoot "sample.mjs")
if ($LASTEXITCODE -ne 0) {
throw "Eve two-runtime sample failed."
}

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>

View file

@ -236,6 +236,33 @@ public sealed class CultMeshDiscoveryServiceTests
}
}
[Test]
public async Task Invalidate_BypassesFreshMemoryAndPersistedRoutesForTheNextLookup()
{
var clock = new ManualClock(new DateTimeOffset(2026, 8, 18, 0, 0, 0, TimeSpan.Zero));
var route = "rudp://first:3076";
var source = new DelegateSource("odin", _ => Task.FromResult<IReadOnlyList<CultMeshDiscoveryObservation>>(
new[] { Observation("aetheria", "odin", clock, TimeSpan.FromMinutes(5), CultMeshDiscoveryTrust.Signed, route) }));
using var cache = new CultCache();
using var service = new CultMeshDiscoveryService(
new[] { source },
new CultMeshDiscoveryServiceOptions
{
Clock = clock,
Store = new CultMeshCultCacheDiscoveryStore(cache)
});
var query = new CultMeshDiscoveryQuery("aetheria", new[] { "aetheria" });
var first = await service.ResolveAsync(query);
route = "rudp://second:3076";
service.Invalidate(query);
var second = await service.ResolveAsync(query);
first.Candidates.Single().Descriptor.DiscoveryEndpoints.Should().Equal("rudp://first:3076");
second.Candidates.Single().Descriptor.DiscoveryEndpoints.Should().Equal("rudp://second:3076");
source.LookupCount.Should().Be(2);
}
private static CultMeshDiscoveryObservation Observation(
string verseId,
string sourceId,

View file

@ -128,7 +128,6 @@ public sealed class CultMeshSessionManagerTests
clients[0].Emit(new CultNetErrorMessage { Error = "before rotation" });
route = "rudp://second:3076";
clock.Advance(TimeSpan.FromSeconds(6));
clients[0].Fail(new IOException("partition"));
await WaitUntilAsync(() => first.State.Status == CultMeshSessionStatus.Online && connector.ConnectCount == 2);
var second = await manager.ConnectAsync(endpoint, CultMeshProtocols.Documents);
@ -142,6 +141,22 @@ public sealed class CultMeshSessionManagerTests
deliveries.Should().Be(2);
}
[Test]
public async Task UriConnectorPreservesTheAdvertisedPath()
{
var client = new FakeUriSchemaClient();
var connector = new CultMeshUriSchemaTransportConnector(
"websocket",
new[] { "ws", "wss" },
_ => client);
var candidate = new CultMeshTransportCandidate("wss://odin.example/game/cultmesh?generation=7");
var connected = await connector.ConnectAsync(candidate, CultMeshProtocols.Documents);
connected.Should().BeSameAs(client);
client.Endpoint.Should().Be(new Uri(candidate.Endpoint));
}
[Test]
public void Connect_ReportsUnsupportedPathPrecisely()
{
@ -326,6 +341,22 @@ public sealed class CultMeshSessionManagerTests
public void Emit(CultNetErrorMessage message) { foreach (var handler in _handlers.ToArray()) handler(message); }
}
private sealed class FakeUriSchemaClient : ICultNetUriSchemaClient
{
public bool Connected => Endpoint != null;
public Uri? Endpoint { get; private set; }
public void Connect(string host, int port) => throw new AssertionException("URI connection was not used.");
public Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Endpoint = endpoint;
return Task.CompletedTask;
}
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage { }
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage { }
public void Dispose() { }
}
private sealed class RespondingSchemaClient : ICultNetSchemaClient
{
private readonly List<Action<CultNetSnapshotResponseRawMessage>> _handlers = new();