diff --git a/package-lock.json b/package-lock.json index 0b8aae8b..74a753a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -846,7 +846,7 @@ } }, "packages/cultmesh-browser": { - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@msgpack/msgpack": "^3.1.3", diff --git a/packages/cultmesh-browser/README.md b/packages/cultmesh-browser/README.md index 62f70585..4f5c7e8b 100644 --- a/packages/cultmesh-browser/README.md +++ b/packages/cultmesh-browser/README.md @@ -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 diff --git a/packages/cultmesh-browser/package.json b/packages/cultmesh-browser/package.json index 7f110506..41833562 100644 --- a/packages/cultmesh-browser/package.json +++ b/packages/cultmesh-browser/package.json @@ -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", diff --git a/packages/cultmesh-browser/src/index.ts b/packages/cultmesh-browser/src/index.ts index b14e05ec..efeb4515 100644 --- a/packages/cultmesh-browser/src/index.ts +++ b/packages/cultmesh-browser/src/index.ts @@ -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> & { transportVersion?: string; @@ -75,7 +75,7 @@ export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous async resolve(identity: CultMeshBrowserIdentity): Promise { 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 { 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, diff --git a/packages/cultmesh-browser/test/cultmesh-browser.test.ts b/packages/cultmesh-browser/test/cultmesh-browser.test.ts index 23f6f5a6..624bdf17 100644 --- a/packages/cultmesh-browser/test/cultmesh-browser.test.ts +++ b/packages/cultmesh-browser/test/cultmesh-browser.test.ts @@ -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); diff --git a/samples/eve-browser-network/Program.cs b/samples/eve-browser-network/Program.cs index e06c235a..4b60a18e 100644 --- a/samples/eve-browser-network/Program.cs +++ b/samples/eve-browser-network/Program.cs @@ -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( "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; } diff --git a/samples/eve-browser-network/browser/main.ts b/samples/eve-browser-network/browser/main.ts index 9e30504d..3947516b 100644 --- a/samples/eve-browser-network/browser/main.ts +++ b/samples/eve-browser-network/browser/main.ts @@ -30,7 +30,7 @@ const receiptOutput = document.querySelector("#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], diff --git a/scripts/verify-eve-getting-started.ps1 b/scripts/verify-eve-getting-started.ps1 index 0a690712..eb653aa6 100644 --- a/scripts/verify-eve-getting-started.ps1 +++ b/scripts/verify-eve-getting-started.ps1 @@ -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()) ` diff --git a/src/GameCult.Mesh/CultMeshClient.cs b/src/GameCult.Mesh/CultMeshClient.cs index 99746a5c..b4667f9c 100644 --- a/src/GameCult.Mesh/CultMeshClient.cs +++ b/src/GameCult.Mesh/CultMeshClient.cs @@ -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(TaskCreationOptions.RunContinuationsAsynchronously); - using var subscription = session.OnCultNet(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( + TaskCreationOptions.RunContinuationsAsynchronously); + var pathChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var generation = session.PhysicalGeneration; + using var responseSubscription = session.OnCultNet(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(envelope, value); } + private async Task 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(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; + } + } + /// Gets the number of currently leased shared document subscriptions. public int ActiveDocumentResourceCount => _documents.Count; diff --git a/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md b/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md index 265247b2..bbeff475 100644 --- a/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md +++ b/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md @@ -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( - "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. diff --git a/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs b/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs index 94194abd..d88b5738 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs @@ -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( + 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(client); } @@ -452,12 +483,18 @@ public sealed class CultMeshClientTests private readonly string _text; private readonly TaskCompletionSource _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 diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll index d99d9ea6..0e9e063f 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb index e110b815..56d08c92 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll index bc68f797..98c10206 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb index 85c08361..6213e7e8 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll index 9c9d4626..d1fe789e 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb index 626a27fa..27bf8a8a 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll index 9c0e55e7..c7ef8430 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb index cf491f8a..f2a26092 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll index a6e2493d..57024e4f 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb index ed81f17f..acac39f4 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll index 45879590..ee85d89f 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb index b072ba89..09055758 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb differ diff --git a/unity/org.gamecult.cultlib/package.json b/unity/org.gamecult.cultlib/package.json index 979cabc8..7c2cce1c 100644 --- a/unity/org.gamecult.cultlib/package.json +++ b/unity/org.gamecult.cultlib/package.json @@ -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",