diff --git a/samples/eve-browser-network/EveBrowserNetworkSample.csproj b/samples/eve-browser-network/EveBrowserNetworkSample.csproj
index a0a0bf32..7de0bdbd 100644
--- a/samples/eve-browser-network/EveBrowserNetworkSample.csproj
+++ b/samples/eve-browser-network/EveBrowserNetworkSample.csproj
@@ -3,9 +3,12 @@
net10.0
enable
enable
+ $(MSBuildThisFileDirectory)..\..\..\Eve
+
diff --git a/samples/eve-browser-network/Program.cs b/samples/eve-browser-network/Program.cs
index 21ef3c32..21933bda 100644
--- a/samples/eve-browser-network/Program.cs
+++ b/samples/eve-browser-network/Program.cs
@@ -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(registry, "sample.counter_state.v1"))
- .Register(CultNetDocumentBinding.ForDocument(registry, "gamecult.eve.surface.v1"));
+ .Register(CultNetDocumentBinding.ForDocument(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(new CultRecordKey(counterKey)) == null)
await database.PutAsync(new CultRecordKey(counterKey), CounterState.Initial(counterKey));
- if (cache.Get(new CultRecordKey(surfaceKey)) == null)
- await database.PutAsync(new CultRecordKey(surfaceKey), CounterSurfaceDocument.Create());
+ if (cache.Get(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(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(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().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(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(StringComparer.Ordinal),
+ [
+ new EveSurfaceComponent(
+ "counter.value",
+ "metric",
+ new Dictionary(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(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 { ["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
- {
- ["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 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 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";
-}
diff --git a/samples/eve-browser-network/README.md b/samples/eve-browser-network/README.md
index a78197a7..5068ce0d 100644
--- a/samples/eve-browser-network/README.md
+++ b/samples/eve-browser-network/README.md
@@ -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.
diff --git a/samples/eve-browser-network/browser/main.ts b/samples/eve-browser-network/browser/main.ts
index 9c7f1cd1..9e30504d 100644
--- a/samples/eve-browser-network/browser/main.ts
+++ b/samples/eve-browser-network/browser/main.ts
@@ -35,7 +35,7 @@ try {
rendezvous: new CultMeshBrowserOdinRendezvous({
endpoints: [odinEndpoint],
runtimeId: "sample.chromium.discovery",
- transportVersion: "cultmesh.v1",
+ transportVersion: "cultmesh.v0",
}),
});
window.__sampleConnectionStates = [];
diff --git a/scripts/verify-eve-browser-network.mjs b/scripts/verify-eve-browser-network.mjs
index 401a8dde..b0f63200 100644
--- a/scripts/verify-eve-browser-network.mjs
+++ b/scripts/verify-eve-browser-network.mjs
@@ -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 });
diff --git a/scripts/verify-eve-two-runtime-sample.ps1 b/scripts/verify-eve-two-runtime-sample.ps1
index 98e81cb9..e33a0622 100644
--- a/scripts/verify-eve-two-runtime-sample.ps1
+++ b/scripts/verify-eve-two-runtime-sample.ps1
@@ -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."
}
diff --git a/src/GameCult.Mesh/CultMeshClient.cs b/src/GameCult.Mesh/CultMeshClient.cs
index 027cc68e..783bb3e0 100644
--- a/src/GameCult.Mesh/CultMeshClient.cs
+++ b/src/GameCult.Mesh/CultMeshClient.cs
@@ -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;
diff --git a/src/GameCult.Mesh/CultMeshDiscoveryService.cs b/src/GameCult.Mesh/CultMeshDiscoveryService.cs
index 964477e3..5e8c43b8 100644
--- a/src/GameCult.Mesh/CultMeshDiscoveryService.cs
+++ b/src/GameCult.Mesh/CultMeshDiscoveryService.cs
@@ -161,6 +161,7 @@ namespace GameCult.Mesh
private readonly CultMeshDiscoveryServiceOptions _options;
private readonly ConcurrentDictionary>> _inFlight = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary _states = new(StringComparer.Ordinal);
+ private readonly ConcurrentDictionary _invalidated = new(StringComparer.Ordinal);
private readonly Subject _updates = new();
private long _diagnosticSequence;
private bool _disposed;
@@ -183,6 +184,19 @@ namespace GameCult.Mesh
return state == null ? null : EvaluateAt(state, _options.Clock.UtcNow);
}
+ ///
+ /// Invalidates one resolved identity so a transport failure must consult the
+ /// configured rendezvous sources again instead of retrying a fresh-but-dead route.
+ ///
+ 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 ResolveAsync(
CultMeshDiscoveryQuery query,
CancellationToken cancellationToken = default)
@@ -315,6 +329,7 @@ namespace GameCult.Mesh
private async Task 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;
diff --git a/src/GameCult.Mesh/CultMeshSessions.cs b/src/GameCult.Mesh/CultMeshSessions.cs
index 0a655ae8..54a19fe8 100644
--- a/src/GameCult.Mesh/CultMeshSessions.cs
+++ b/src/GameCult.Mesh/CultMeshSessions.cs
@@ -110,6 +110,66 @@ namespace GameCult.Mesh
CancellationToken cancellationToken = default);
}
+ ///
+ /// Connects exact URI routes through a caller-supplied URI-capable CultNet client.
+ /// This keeps transport packages outside CultMesh while preserving advertised paths.
+ ///
+ public sealed class CultMeshUriSchemaTransportConnector : ICultMeshTransportConnector
+ {
+ private readonly HashSet _schemes;
+ private readonly Func _createClient;
+
+ public CultMeshUriSchemaTransportConnector(
+ string connectorId,
+ IEnumerable schemes,
+ Func createClient,
+ int priority = 0)
+ {
+ if (string.IsNullOrWhiteSpace(connectorId))
+ throw new ArgumentException("Connector identity is required.", nameof(connectorId));
+ ConnectorId = connectorId.Trim();
+ _schemes = new HashSet(
+ (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 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;
+ }
+ }
+ }
+
///
/// Explicit compatibility connector for the previous RUDP/LiteNetLib schema lanes.
/// Prefer 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(
diff --git a/src/GameCult.Mesh/CultMeshVerseDiscovery.cs b/src/GameCult.Mesh/CultMeshVerseDiscovery.cs
index ca3655e9..801e4716 100644
--- a/src/GameCult.Mesh/CultMeshVerseDiscovery.cs
+++ b/src/GameCult.Mesh/CultMeshVerseDiscovery.cs
@@ -182,6 +182,9 @@ namespace GameCult.Mesh
/// Gets or sets the identity of this bootstrap lookup source.
public string SourceId { get; set; } = "configured-bootstrap";
+ /// Gets or sets the CultMesh transport contract requested from rendezvous.
+ public string TransportVersion { get; set; } = "cultmesh.v0";
+
/// Gets or sets how long a successful compatibility observation remains fresh.
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(
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
diff --git a/src/GameCult.Mesh/docs/browser-verse-transport.md b/src/GameCult.Mesh/docs/browser-verse-transport.md
index c6748b28..92c10844 100644
--- a/src/GameCult.Mesh/docs/browser-verse-transport.md
+++ b/src/GameCult.Mesh/docs/browser-verse-transport.md
@@ -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:
diff --git a/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md b/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md
index 91f742ac..06547e74 100644
--- a/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md
+++ b/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md
@@ -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).
diff --git a/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaClient.cs b/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaClient.cs
index 2e778a0a..0a233ab7 100644
--- a/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaClient.cs
+++ b/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaClient.cs
@@ -5,7 +5,7 @@ using GameCult.Networking;
namespace GameCult.Networking.WebSockets;
/// Schema-v0 client over a binary WebSocket route.
-public sealed class CultNetWebSocketSchemaClient : ICultNetSchemaClient, ICultNetSchemaClientHealth
+public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICultNetSchemaClientHealth
{
private readonly ConcurrentDictionary> _handlers = new();
private readonly object _handlerGate = new();
diff --git a/src/GameCult.Networking/CultNetSchemaClient.cs b/src/GameCult.Networking/CultNetSchemaClient.cs
index 4071cd1b..5c6117aa 100644
--- a/src/GameCult.Networking/CultNetSchemaClient.cs
+++ b/src/GameCult.Networking/CultNetSchemaClient.cs
@@ -38,6 +38,17 @@ namespace GameCult.Networking
where T : ICultNetSchemaMessage;
}
+ ///
+ /// 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.
+ ///
+ public interface ICultNetUriSchemaClient : ICultNetSchemaClient
+ {
+ /// Connects to the exact advertised schema endpoint.
+ Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken = default);
+ }
+
///
/// Exposes terminal failure of a schema client's background transport work.
///
diff --git a/tests/GameCult.Mesh.Tests/CultMeshDiscoveryServiceTests.cs b/tests/GameCult.Mesh.Tests/CultMeshDiscoveryServiceTests.cs
index 1b0061d6..af6334f8 100644
--- a/tests/GameCult.Mesh.Tests/CultMeshDiscoveryServiceTests.cs
+++ b/tests/GameCult.Mesh.Tests/CultMeshDiscoveryServiceTests.cs
@@ -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>(
+ 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,
diff --git a/tests/GameCult.Mesh.Tests/CultMeshSessionManagerTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSessionManagerTests.cs
index 42e6d962..4621c76b 100644
--- a/tests/GameCult.Mesh.Tests/CultMeshSessionManagerTests.cs
+++ b/tests/GameCult.Mesh.Tests/CultMeshSessionManagerTests.cs
@@ -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 message) where T : ICultNetSchemaMessage { }
+ public void OnCultNet(Action callback) where T : ICultNetSchemaMessage { }
+ public void Dispose() { }
+ }
+
private sealed class RespondingSchemaClient : ICultNetSchemaClient
{
private readonly List> _handlers = new();