Make CultMesh identity and route recovery explicit

This commit is contained in:
Meta Vens 2026-08-18 10:42:33 +02:00
commit 21163d83fa
24 changed files with 179 additions and 94 deletions

2
package-lock.json generated
View file

@ -846,7 +846,7 @@
}
},
"packages/cultmesh-browser": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",

View file

@ -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

View file

@ -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",

View file

@ -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,

View file

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

View file

@ -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; }

View file

@ -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],

View file

@ -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()) `

View file

@ -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;

View file

@ -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.

View file

@ -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

View file

@ -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",