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