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