Make CultMesh identity and route recovery explicit
This commit is contained in:
parent
b542f4b644
commit
21163d83fa
24 changed files with 179 additions and 94 deletions
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -846,7 +846,7 @@
|
|||
}
|
||||
},
|
||||
"packages/cultmesh-browser": {
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# CultMesh Browser
|
||||
|
||||
`cultmesh-browser` is the browser-safe CultMesh client boundary. It owns one
|
||||
WebSocket session for a stable Verse/provider identity, retains explicit raw
|
||||
WebSocket session for a stable Verse/authority-runtime target, retains explicit raw
|
||||
document leases across route replacement, and correlates typed CultNet
|
||||
operations with provider receipts.
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ const odin = new CultMeshBrowserOdinRendezvous({
|
|||
|
||||
const mesh = await CultMeshBrowserClient.connect({
|
||||
verseId: "sample.counter",
|
||||
providerId: "sample.counter-provider",
|
||||
authorityRuntimeId: "sample.counter-daemon",
|
||||
runtimeId: "counter-browser",
|
||||
rendezvous: odin,
|
||||
});
|
||||
|
|
@ -38,9 +38,10 @@ responses because the domain still owns their receipt schema.
|
|||
|
||||
`CultMeshBrowserOdinRendezvous` speaks the canonical CultNet Verse-catalog
|
||||
messages to one or more configured Odin WebSocket endpoints. Application code
|
||||
supplies stable identity; it does not own endpoint selection, reconnect, or
|
||||
resubscription. The physical provider socket is a replaceable route, not the
|
||||
provider identity.
|
||||
supplies the Verse and authority-runtime identity; it does not own endpoint
|
||||
selection, reconnect, or resubscription. This is deliberately distinct from an
|
||||
Eve surface's `providerId`, which identifies the UI provider inside the Verse.
|
||||
The physical socket is a replaceable route, not either stable identity.
|
||||
|
||||
The browser handshake carries authentication through the host application's
|
||||
ordinary secure cookie or equivalent upgrade credential. The C# host adapter
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "cultmesh-browser",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
|
||||
export interface CultMeshBrowserIdentity {
|
||||
verseId: string;
|
||||
providerId: string;
|
||||
authorityRuntimeId: string;
|
||||
}
|
||||
|
||||
export interface CultMeshBrowserRoute extends CultMeshBrowserIdentity {
|
||||
|
|
@ -49,7 +49,7 @@ export interface CultMeshBrowserSocket {
|
|||
close(code?: number, reason?: string): void;
|
||||
}
|
||||
|
||||
/** Resolves stable Verse/provider identity through Odin's canonical CultNet Verse catalog. */
|
||||
/** Resolves a stable Verse/authority-runtime target through Odin's canonical CultNet Verse catalog. */
|
||||
export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous {
|
||||
#options: Required<Omit<CultMeshBrowserOdinRendezvousOptions, "transportVersion" | "socketFactory">> & {
|
||||
transportVersion?: string;
|
||||
|
|
@ -75,7 +75,7 @@ export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous
|
|||
|
||||
async resolve(identity: CultMeshBrowserIdentity): Promise<CultMeshBrowserRoute> {
|
||||
requireText(identity.verseId, "verseId");
|
||||
requireText(identity.providerId, "providerId");
|
||||
requireText(identity.authorityRuntimeId, "authorityRuntimeId");
|
||||
const failures: Error[] = [];
|
||||
for (const endpoint of this.#options.endpoints) {
|
||||
try {
|
||||
|
|
@ -86,7 +86,7 @@ export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous
|
|||
}
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
`Odin could not resolve Verse '${identity.verseId}' provider '${identity.providerId}'.`,
|
||||
`Odin could not resolve Verse '${identity.verseId}' authority runtime '${identity.authorityRuntimeId}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -281,9 +281,9 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
|||
|
||||
private constructor(options: CultMeshBrowserClientOptions) {
|
||||
requireText(options.verseId, "verseId");
|
||||
requireText(options.providerId, "providerId");
|
||||
requireText(options.authorityRuntimeId, "authorityRuntimeId");
|
||||
requireText(options.runtimeId, "runtimeId");
|
||||
this.identity = { verseId: options.verseId, providerId: options.providerId };
|
||||
this.identity = { verseId: options.verseId, authorityRuntimeId: options.authorityRuntimeId };
|
||||
this.runtimeId = options.runtimeId;
|
||||
this.#options = {
|
||||
...options,
|
||||
|
|
@ -477,7 +477,8 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
|||
|
||||
private async openSocket(generation: number): Promise<void> {
|
||||
const route = await this.#options.rendezvous.resolve(this.identity);
|
||||
if (route.verseId !== this.identity.verseId || route.providerId !== this.identity.providerId) {
|
||||
if (route.verseId !== this.identity.verseId ||
|
||||
route.authorityRuntimeId !== this.identity.authorityRuntimeId) {
|
||||
throw new Error("CultMesh rendezvous returned a route for the wrong stable identity.");
|
||||
}
|
||||
const endpoint = new URL(route.endpoint);
|
||||
|
|
@ -733,10 +734,11 @@ function selectOdinRoute(
|
|||
identity: CultMeshBrowserIdentity,
|
||||
): CultMeshBrowserRoute {
|
||||
const matchingVerses = response.verses.filter(verse =>
|
||||
verse.verseId === identity.verseId && verse.authorityRuntimeIds.includes(identity.providerId));
|
||||
verse.verseId === identity.verseId &&
|
||||
verse.authorityRuntimeIds.includes(identity.authorityRuntimeId));
|
||||
if (matchingVerses.length !== 1) {
|
||||
throw new Error(
|
||||
`Odin returned ${matchingVerses.length} routes for Verse '${identity.verseId}' provider '${identity.providerId}'.`,
|
||||
`Odin returned ${matchingVerses.length} routes for Verse '${identity.verseId}' authority runtime '${identity.authorityRuntimeId}'.`,
|
||||
);
|
||||
}
|
||||
const verse = matchingVerses[0];
|
||||
|
|
@ -749,7 +751,7 @@ function selectOdinRoute(
|
|||
}
|
||||
});
|
||||
if (endpoints.length === 0) {
|
||||
throw new Error(`Odin advertised no browser-compatible route for '${identity.providerId}'.`);
|
||||
throw new Error(`Odin advertised no browser-compatible route for authority runtime '${identity.authorityRuntimeId}'.`);
|
||||
}
|
||||
return {
|
||||
...identity,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
type CultMeshBrowserSocket,
|
||||
} from "../src/index.js";
|
||||
|
||||
test("Odin rendezvous resolves stable provider identity and observes route rotation", async () => {
|
||||
test("Odin rendezvous resolves stable authority-runtime identity and observes route rotation", async () => {
|
||||
const odin = new FakeOdin("ws://127.0.0.1:4050/mesh");
|
||||
let id = 0;
|
||||
const rendezvous = new CultMeshBrowserOdinRendezvous({
|
||||
|
|
@ -33,17 +33,17 @@ test("Odin rendezvous resolves stable provider identity and observes route rotat
|
|||
|
||||
assert.equal((await rendezvous.resolve({
|
||||
verseId: "sample.counter",
|
||||
providerId: "sample.counter-provider",
|
||||
authorityRuntimeId: "sample.counter-daemon",
|
||||
})).endpoint, "ws://127.0.0.1:4050/mesh");
|
||||
|
||||
odin.providerEndpoint = "ws://127.0.0.1:4060/mesh";
|
||||
assert.equal((await rendezvous.resolve({
|
||||
verseId: "sample.counter",
|
||||
providerId: "sample.counter-provider",
|
||||
authorityRuntimeId: "sample.counter-daemon",
|
||||
})).endpoint, "ws://127.0.0.1:4060/mesh");
|
||||
|
||||
await assert.rejects(
|
||||
rendezvous.resolve({ verseId: "sample.counter", providerId: "other-provider" }),
|
||||
rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "other-runtime" }),
|
||||
/could not resolve/,
|
||||
);
|
||||
});
|
||||
|
|
@ -55,7 +55,7 @@ test("browser client leases provider state, invokes an operation, and follows ro
|
|||
let id = 0;
|
||||
const client = await CultMeshBrowserClient.connect({
|
||||
verseId: route.verseId,
|
||||
providerId: route.providerId,
|
||||
authorityRuntimeId: route.authorityRuntimeId,
|
||||
runtimeId: "browser-test",
|
||||
rendezvous: { resolve: async () => route },
|
||||
createId: () => `id-${++id}`,
|
||||
|
|
@ -101,12 +101,12 @@ test("browser client rejects wrong rendezvous identity and has no document write
|
|||
await assert.rejects(
|
||||
CultMeshBrowserClient.connect({
|
||||
verseId: "sample.counter",
|
||||
providerId: "sample.counter-provider",
|
||||
authorityRuntimeId: "sample.counter-daemon",
|
||||
runtimeId: "browser-test",
|
||||
rendezvous: {
|
||||
resolve: async () => ({
|
||||
...provider.route,
|
||||
providerId: "intruder-provider",
|
||||
authorityRuntimeId: "intruder-runtime",
|
||||
}),
|
||||
},
|
||||
socketFactory: () => provider.open(),
|
||||
|
|
@ -120,7 +120,7 @@ test("browser client rejects wrong rendezvous identity and has no document write
|
|||
test("browser client bounds unanswered provider operations", async () => {
|
||||
const route = {
|
||||
verseId: "sample.counter",
|
||||
providerId: "sample.counter-provider",
|
||||
authorityRuntimeId: "sample.counter-daemon",
|
||||
endpoint: "ws://127.0.0.1:4301/mesh",
|
||||
};
|
||||
const client = await CultMeshBrowserClient.connect({
|
||||
|
|
@ -175,7 +175,7 @@ test("browser client rejects a correlated framework failure without waiting for
|
|||
test("browser client rejects oversized outbound schema messages", async () => {
|
||||
const route = {
|
||||
verseId: "sample.counter",
|
||||
providerId: "sample.counter-provider",
|
||||
authorityRuntimeId: "sample.counter-daemon",
|
||||
endpoint: "ws://127.0.0.1:4401/mesh",
|
||||
};
|
||||
await assert.rejects(
|
||||
|
|
@ -224,7 +224,7 @@ class FakeProvider {
|
|||
constructor(endpoint: string, count: number, rejectOperations = false) {
|
||||
this.route = {
|
||||
verseId: "sample.counter",
|
||||
providerId: "sample.counter-provider",
|
||||
authorityRuntimeId: "sample.counter-daemon",
|
||||
endpoint,
|
||||
generation: endpoint,
|
||||
};
|
||||
|
|
@ -288,7 +288,7 @@ class FakeProvider {
|
|||
message: "Expected payload schema 'sample.increment.v1'.",
|
||||
})),
|
||||
diagnostics: ["Request schema did not match."],
|
||||
sourceRuntimeId: "sample.counter-provider",
|
||||
sourceRuntimeId: "sample.counter-daemon",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -314,7 +314,7 @@ class FakeProvider {
|
|||
payloadSchema: "sample.increment_receipt.v1",
|
||||
payloadEncoding: "messagepack-base64",
|
||||
payload: bytesToBase64(encode({ count: this.#count })),
|
||||
sourceRuntimeId: "sample.counter-provider",
|
||||
sourceRuntimeId: "sample.counter-daemon",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -328,7 +328,7 @@ class FakeProvider {
|
|||
storedAt: "2026-08-17T00:00:00Z",
|
||||
payloadEncoding: "messagepack",
|
||||
payload: encode({ count: this.#count }),
|
||||
sourceRuntimeId: "sample.counter-provider",
|
||||
sourceRuntimeId: "sample.counter-daemon",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -359,7 +359,7 @@ class FakeOdin {
|
|||
optionalPluginIds: [],
|
||||
},
|
||||
discoveryEndpoints: [this.providerEndpoint],
|
||||
authorityRuntimeIds: ["sample.counter-provider"],
|
||||
authorityRuntimeIds: ["sample.counter-daemon"],
|
||||
}],
|
||||
});
|
||||
}, () => undefined);
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ static async Task RunProviderAsync(Args arguments)
|
|||
},
|
||||
DatabaseOptions = new CultNetDatabaseOptions
|
||||
{
|
||||
RuntimeId = "sample.counter-provider",
|
||||
RuntimeId = arguments.AuthorityRuntimeId,
|
||||
DocumentRegistry = documents
|
||||
}
|
||||
});
|
||||
|
|
@ -99,7 +99,7 @@ static async Task RunProviderAsync(Args arguments)
|
|||
await using var schemaServer = new CultNetWebSocketSchemaServer();
|
||||
using var subscriptions = new CultNetDatabaseSubscriptionServer(schemaServer, node.Database);
|
||||
using var operationGate = new SemaphoreSlim(1, 1);
|
||||
using var operations = new CultNetOperationServer(schemaServer, "sample.counter-provider")
|
||||
using var operations = new CultNetOperationServer(schemaServer, arguments.AuthorityRuntimeId)
|
||||
.Register<PingRequest, PingReceipt>(
|
||||
"sample.counter",
|
||||
"sample.counter.ping",
|
||||
|
|
@ -329,7 +329,7 @@ static EveSurfaceDocument CreateCounterSurface()
|
|||
"sample.increment.v1",
|
||||
networkRoute);
|
||||
return EveSurface.Create(surfaceKey)
|
||||
.Provider("sample.counter-provider", "sample.daemon")
|
||||
.Provider("sample.counter-ui", "sample.daemon")
|
||||
.Title("CultMesh browser counter")
|
||||
.Version(1)
|
||||
.UpdatedAtUtc("2026-08-17T00:00:00Z")
|
||||
|
|
@ -350,7 +350,7 @@ sealed class Args
|
|||
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 AuthorityRuntimeId { get; private init; } = "sample.counter-daemon";
|
||||
public string TransportVersion { get; private init; } = "cultmesh.v0";
|
||||
public string RulesHash { get; private init; } = "sample-counter-v1";
|
||||
public int ExpectedCount { get; private init; } = 2;
|
||||
|
|
@ -372,7 +372,7 @@ sealed class Args
|
|||
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"),
|
||||
AuthorityRuntimeId = named.GetValueOrDefault("--authority-runtime-id", "sample.counter-daemon"),
|
||||
TransportVersion = named.GetValueOrDefault("--transport-version", "cultmesh.v0"),
|
||||
RulesHash = named.GetValueOrDefault("--rules-hash", "sample-counter-v1"),
|
||||
ExpectedCount = named.TryGetValue("--expected-count", out var expectedCount)
|
||||
|
|
@ -411,7 +411,7 @@ public sealed class IncrementReceipt
|
|||
[Key("state")] public string State { get; set; } = "accepted";
|
||||
[Key("ownerRepo")] public string OwnerRepo { get; set; } = "CultLib";
|
||||
[Key("authority")] public string Authority { get; set; } = "provider-daemon";
|
||||
[Key("providerId")] public string ProviderId { get; set; } = "sample.counter-provider";
|
||||
[Key("providerId")] public string ProviderId { get; set; } = "sample.counter-ui";
|
||||
[Key("surfaceId")] public string SurfaceId { get; set; } = "sample.counter";
|
||||
[Key("issuedAtUtc")] public string IssuedAtUtc { get; set; } = "";
|
||||
[Key("sourceVersion")] public int SourceVersion { get; set; }
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ const receiptOutput = document.querySelector<HTMLOutputElement>("#receipt")!;
|
|||
try {
|
||||
const mesh = await CultMeshBrowserClient.connect({
|
||||
verseId: "sample.counter",
|
||||
providerId: "sample.counter-provider",
|
||||
authorityRuntimeId: "sample.counter-daemon",
|
||||
runtimeId: "sample.chromium",
|
||||
rendezvous: new CultMeshBrowserOdinRendezvous({
|
||||
endpoints: [odinEndpoint],
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ foreach ($requiredPrimitive in @("CultNetOperationServer", "EveSurface.Create"))
|
|||
}
|
||||
}
|
||||
if ($networkSampleSource -notmatch 'new\s+CultMeshSessionTarget\s*\(') {
|
||||
throw "The getting-started client must address one explicit Verse/provider target."
|
||||
throw "The getting-started client must address one explicit Verse/authority-runtime target."
|
||||
}
|
||||
$cultMeshClientSource = Get-Content -LiteralPath (Join-Path $cultLibRoot "src\GameCult.Mesh\CultMeshClient.cs") -Raw
|
||||
if ($cultMeshClientSource -match 'ConnectAsync\s*\(\s*string\s+(endpointId|verseId)' -or
|
||||
|
|
@ -27,7 +27,7 @@ if ($cultMeshClientSource -match 'ConnectAsync\s*\(\s*string\s+(endpointId|verse
|
|||
}
|
||||
$discoverySource = Get-Content -LiteralPath (Join-Path $cultLibRoot "src\GameCult.Mesh\CultMeshDiscoveryService.cs") -Raw
|
||||
if ($discoverySource -notmatch 'AuthorityRuntimeIds\.Contains\(query\.AuthorityRuntimeId') {
|
||||
throw "CultMesh discovery must prove that a selected Verse route advertises the requested provider runtime."
|
||||
throw "CultMesh discovery must prove that a selected Verse route advertises the requested authority runtime."
|
||||
}
|
||||
|
||||
$dotnetPackageRoot = Join-Path ([IO.Path]::GetTempPath()) `
|
||||
|
|
|
|||
|
|
@ -383,18 +383,7 @@ namespace GameCult.Mesh
|
|||
var expectedOperation = operation.Trim();
|
||||
var expectedResponseSchema = responseSchema.Trim();
|
||||
var messageId = idempotencyKey.Trim();
|
||||
var session = await ConnectAsync(target, CultMeshProtocols.Documents, cancellationToken).ConfigureAwait(false);
|
||||
var completion = new TaskCompletionSource<CultNetOperationResponseMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var subscription = session.OnCultNet<CultNetOperationResponseMessage>(response =>
|
||||
{
|
||||
if (string.Equals(response.MessageId, messageId, StringComparison.Ordinal))
|
||||
completion.TrySetResult(response);
|
||||
});
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(_operationResponseTimeout);
|
||||
using var cancellation = timeout.Token.Register(() => completion.TrySetCanceled(timeout.Token));
|
||||
|
||||
session.SendCultNet(new CultNetOperationRequestMessage
|
||||
var message = new CultNetOperationRequestMessage
|
||||
{
|
||||
MessageId = messageId,
|
||||
ServiceId = expectedService,
|
||||
|
|
@ -403,14 +392,46 @@ namespace GameCult.Mesh
|
|||
PayloadEncoding = "messagepack-base64",
|
||||
Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(request, CultNetSchemaMessageSerialization.Options)),
|
||||
SourceRuntimeId = sourceRuntimeId.Trim()
|
||||
});
|
||||
|
||||
};
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(_operationResponseTimeout);
|
||||
CultNetOperationResponseMessage envelope;
|
||||
try
|
||||
{
|
||||
envelope = await completion.Task.ConfigureAwait(false);
|
||||
while (true)
|
||||
{
|
||||
var session = await ConnectOnlineAsync(target, timeout.Token).ConfigureAwait(false);
|
||||
var completion = new TaskCompletionSource<CultNetOperationResponseMessage>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var pathChanged = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var generation = session.PhysicalGeneration;
|
||||
using var responseSubscription = session.OnCultNet<CultNetOperationResponseMessage>(response =>
|
||||
{
|
||||
if (string.Equals(response.MessageId, messageId, StringComparison.Ordinal))
|
||||
completion.TrySetResult(response);
|
||||
});
|
||||
using var stateSubscription = session.WatchState().Subscribe(state =>
|
||||
{
|
||||
if (state.Status != CultMeshSessionStatus.Online || session.PhysicalGeneration != generation)
|
||||
pathChanged.TrySetResult(true);
|
||||
});
|
||||
using var cancellation = timeout.Token.Register(() => completion.TrySetCanceled(timeout.Token));
|
||||
if (session.State.Status != CultMeshSessionStatus.Online ||
|
||||
session.PhysicalGeneration != generation)
|
||||
continue;
|
||||
|
||||
session.SendCultNet(message);
|
||||
var finished = await Task.WhenAny(completion.Task, pathChanged.Task).ConfigureAwait(false);
|
||||
if (finished == completion.Task)
|
||||
{
|
||||
envelope = await completion.Task.ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
// The operation has a durable idempotency key. A route loss before a
|
||||
// correlated response replays the same request after replacement finality.
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"CultMesh operation '{expectedService}/{expectedOperation}' did not answer idempotency key '{messageId}' " +
|
||||
|
|
@ -462,6 +483,32 @@ namespace GameCult.Mesh
|
|||
return new CultMeshOperationResult<TResponse>(envelope, value);
|
||||
}
|
||||
|
||||
private async Task<CultMeshSession> ConnectOnlineAsync(
|
||||
CultMeshSessionTarget target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var session = await ConnectAsync(target, CultMeshProtocols.Documents, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (session.State.Status == CultMeshSessionStatus.Online)
|
||||
return session;
|
||||
|
||||
var changed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var subscription = session.WatchState().Subscribe(state =>
|
||||
{
|
||||
if (state.Status == CultMeshSessionStatus.Online || state.Status == CultMeshSessionStatus.Offline)
|
||||
changed.TrySetResult(true);
|
||||
});
|
||||
using var cancellation = cancellationToken.Register(() => changed.TrySetCanceled(cancellationToken));
|
||||
if (session.State.Status != CultMeshSessionStatus.Reconnecting)
|
||||
continue;
|
||||
await changed.Task.ConfigureAwait(false);
|
||||
if (session.State.Status == CultMeshSessionStatus.Online)
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the number of currently leased shared document subscriptions.</summary>
|
||||
public int ActiveDocumentResourceCount => _documents.Count;
|
||||
|
||||
|
|
|
|||
|
|
@ -34,50 +34,42 @@ import provider internals.
|
|||
Install the renderer-neutral `GameCult.Eve.Surface` package in the provider.
|
||||
Do not install EveUnity or the browser lowerer there. A provider defines typed
|
||||
state, one typed operation transaction, and one Eve surface. CultNet envelope
|
||||
encoding is framework work:
|
||||
encoding is framework work. This complete method builds the surface document;
|
||||
it has no hidden transport or state variables:
|
||||
|
||||
```csharp
|
||||
using GameCult.Eve.Surface;
|
||||
using GameCult.Mesh;
|
||||
using GameCult.Networking;
|
||||
|
||||
using var operations = new CultNetOperationServer(schemaServer, "sample.counter-provider")
|
||||
.Register<IncrementRequest, IncrementReceipt>(
|
||||
"sample.counter",
|
||||
static EveSurfaceDocument CreateCounterSurface()
|
||||
{
|
||||
var route = new CultMeshRouteHint(CultMeshLocalityKind.Network, "cultmesh");
|
||||
var count = new CultMeshStateBindingDescriptor(
|
||||
"value",
|
||||
"sample.counter.count",
|
||||
"sample.counter_state:counter:main",
|
||||
"sample.counter_state.v1",
|
||||
route);
|
||||
var increment = CultMesh.OperationBinding(
|
||||
"sample.counter.increment",
|
||||
"Increment",
|
||||
"sample.increment.v1",
|
||||
"gamecult.eve.command_receipt.v1",
|
||||
async command => await IncrementExactlyOnceAsync(
|
||||
command.IdempotencyKey,
|
||||
command.Value));
|
||||
route);
|
||||
|
||||
var route = new CultMeshRouteHint(CultMeshLocalityKind.Network, "cultmesh");
|
||||
var count = new CultMeshStateBindingDescriptor(
|
||||
"value",
|
||||
"sample.counter.count",
|
||||
"sample.counter_state:counter:main",
|
||||
"sample.counter_state.v1",
|
||||
route);
|
||||
var increment = CultMesh.OperationBinding(
|
||||
"sample.counter.increment",
|
||||
"Increment",
|
||||
"sample.increment.v1",
|
||||
route);
|
||||
|
||||
var surface = EveSurface.Create("sample.counter")
|
||||
.Provider("sample.counter-provider", "sample.daemon")
|
||||
.Title("CultMesh browser counter")
|
||||
.RootColumn("counter.root", root => root
|
||||
.Metric("counter.value", "Canonical count", "0", count)
|
||||
.Button("counter.increment", "Increment", increment))
|
||||
.Build();
|
||||
return EveSurface.Create("sample.counter")
|
||||
.Provider("sample.counter-ui", "sample.daemon")
|
||||
.Title("CultMesh browser counter")
|
||||
.RootColumn("counter.root", root => root
|
||||
.Metric("counter.value", "Canonical count", "0", count)
|
||||
.Button("counter.increment", "Increment", increment))
|
||||
.Build();
|
||||
}
|
||||
```
|
||||
|
||||
`schemaServer` is a transport-neutral CultNet server port. WebSocket, RUDP, and
|
||||
test transports attach the same dispatcher. The handler owns validation,
|
||||
idempotency, state mutation, and its durable receipt. `CultNetOperationServer`
|
||||
owns only route/schema checks and typed envelope serialization. The complete
|
||||
provider transaction is in
|
||||
The operation handler owns validation, idempotency, state mutation, and its
|
||||
durable receipt. `CultNetOperationServer` owns only route/schema checks and
|
||||
typed envelope serialization. The complete, compiling provider transaction—including
|
||||
the schema server, typed request/receipt types, and exactly-once mutation—is in
|
||||
[`samples/eve-browser-network/Program.cs`](../../../../samples/eve-browser-network/Program.cs);
|
||||
it contains no hand-written operation base64 or MessagePack dispatch switch.
|
||||
|
||||
|
|
|
|||
|
|
@ -311,6 +311,35 @@ public sealed class CultMeshClientTests
|
|||
.Which.SourceRuntimeId.Should().Be("pilot-one");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task InvokeOperation_ReplaysSameIdempotencyKeyAfterRouteLossBeforeReceipt()
|
||||
{
|
||||
var connector = new DocumentConnector { FailFirstOperationBeforeResponse = true };
|
||||
using var mesh = new CultMeshClient(new CultMeshClientOptions
|
||||
{
|
||||
RendezvousEndpoints = new[] { "rudp://odin:3076" },
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
|
||||
Connectors = new[] { connector },
|
||||
OperationResponseTimeout = TimeSpan.FromSeconds(2)
|
||||
});
|
||||
|
||||
var result = await mesh.InvokeAsync<ClientOperationRequest, ClientOperationReceipt>(
|
||||
Target,
|
||||
"tests.counter",
|
||||
"tests.counter.increment",
|
||||
"tests.counter.increment_request.v1",
|
||||
"tests.counter.increment_receipt.v1",
|
||||
new ClientOperationRequest { Amount = 2 },
|
||||
sourceRuntimeId: "pilot-one",
|
||||
idempotencyKey: "pilot-command-route-loss");
|
||||
|
||||
result.Value.ReceiptId.Should().Be("receipt:pilot-command-route-loss");
|
||||
connector.Clients.Should().HaveCount(2);
|
||||
connector.Clients.SelectMany(client => client.Operations)
|
||||
.Select(operation => operation.MessageId)
|
||||
.Should().Equal("pilot-command-route-loss", "pilot-command-route-loss");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task InvokeOperation_ThrowsCorrelatedFrameworkFailureWithoutTimingOut()
|
||||
{
|
||||
|
|
@ -424,6 +453,7 @@ public sealed class CultMeshClientTests
|
|||
public bool SuppressFirstSnapshot { get; set; }
|
||||
public bool SuppressFirstSubscriptionSnapshot { get; set; }
|
||||
public bool RejectOperations { get; set; }
|
||||
public bool FailFirstOperationBeforeResponse { get; set; }
|
||||
public string ConnectorId => "document-test";
|
||||
public int Priority => 0;
|
||||
public int SubscribeCount => Clients.Sum(client => client.SubscribeCount);
|
||||
|
|
@ -438,7 +468,8 @@ public sealed class CultMeshClientTests
|
|||
snapshotsToSuppress: SuppressFirstSnapshot && Clients.Count == 0
|
||||
? int.MaxValue
|
||||
: SuppressFirstSubscriptionSnapshot && Clients.Count == 0 ? 1 : 0,
|
||||
rejectOperations: RejectOperations);
|
||||
rejectOperations: RejectOperations,
|
||||
failOperationBeforeResponse: FailFirstOperationBeforeResponse && Clients.Count == 0);
|
||||
Clients.Add(client);
|
||||
return Task.FromResult<ICultNetSchemaClient>(client);
|
||||
}
|
||||
|
|
@ -452,12 +483,18 @@ public sealed class CultMeshClientTests
|
|||
private readonly string _text;
|
||||
private readonly TaskCompletionSource<Exception> _failure = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly bool _rejectOperations;
|
||||
private bool _failOperationBeforeResponse;
|
||||
private int _snapshotsToSuppress;
|
||||
public DocumentClient(string text, int snapshotsToSuppress = 0, bool rejectOperations = false)
|
||||
public DocumentClient(
|
||||
string text,
|
||||
int snapshotsToSuppress = 0,
|
||||
bool rejectOperations = false,
|
||||
bool failOperationBeforeResponse = false)
|
||||
{
|
||||
_text = text;
|
||||
_snapshotsToSuppress = snapshotsToSuppress;
|
||||
_rejectOperations = rejectOperations;
|
||||
_failOperationBeforeResponse = failOperationBeforeResponse;
|
||||
var cache = CultMesh.CreateCultCacheDocumentRegistry(typeof(ClientTestDocument));
|
||||
_documents = CultMesh.CreateCultNetDocumentRegistry(new[] { typeof(ClientTestDocument) }, cache);
|
||||
}
|
||||
|
|
@ -478,6 +515,12 @@ public sealed class CultMeshClientTests
|
|||
if (message is CultNetOperationRequestMessage operation)
|
||||
{
|
||||
Operations.Add(operation);
|
||||
if (_failOperationBeforeResponse)
|
||||
{
|
||||
_failOperationBeforeResponse = false;
|
||||
Fail(new InvalidOperationException("path lost before operation receipt"));
|
||||
return;
|
||||
}
|
||||
if (_rejectOperations)
|
||||
{
|
||||
var failure = new CultNetOperationFailure
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "org.gamecult.cultlib",
|
||||
"displayName": "CultLib",
|
||||
"version": "1.0.46",
|
||||
"version": "1.0.47",
|
||||
"unity": "2021.3",
|
||||
"description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
Loading…
Reference in a new issue