Recover authority-bound CultMesh and atomic generations

This commit is contained in:
Meta Vens 2026-08-26 19:01:24 +02:00
commit 334e60f192
92 changed files with 6071 additions and 688 deletions

View file

@ -14,7 +14,8 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<Copyright>© Meta Vens and contributors</Copyright>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<Version>1.0.33</Version>
<Version>1.0.35</Version>
<Version Condition="'$(CultLibPackageVersion)' != ''">$(CultLibPackageVersion)</Version>
<!-- Symbols/debugging -->
<EmbedUntrackedSources>true</EmbedUntrackedSources>

8
package-lock.json generated
View file

@ -846,11 +846,11 @@
}
},
"packages/cultmesh-browser": {
"version": "0.2.0",
"version": "0.3.0",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"cultnet-ts": "^0.1.1"
"cultnet-ts": "^0.2.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
@ -866,7 +866,7 @@
"dependencies": {
"@msgpack/msgpack": "^3.1.2",
"cultcache-ts": "^0.13.5",
"cultnet-ts": "^0.1.1"
"cultnet-ts": "^0.2.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
@ -878,7 +878,7 @@
}
},
"packages/cultnet-ts": {
"version": "0.1.1",
"version": "0.2.0",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",

View file

@ -43,10 +43,19 @@ 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.
Odin returns exact authority-route bindings. The browser selects only routes
bound to the requested authority and `cultmesh.documents.v1`, then performs a
session-open handshake. It does not report `connected` until the peer proves
the exact Verse, authority runtime, protocol, and route generation. Operation
responses must also name that authority. Legacy catalogs are accepted only
when one authority runtime makes the endpoint binding unambiguous.
The browser handshake carries authentication through the host application's
ordinary secure cookie or equivalent upgrade credential. The C# host adapter
rejects endpoints without an authorization predicate unless anonymous local
development is explicitly enabled.
Run `scripts/verify-eve-browser-network.mjs --eve-root <path-to-Eve>` for the
real Chromium + C# headless chronology.
real Chromium + C# headless chronology. The witness publishes a lower-priority
decoy endpoint owned by another runtime and proves that neither client connects
to it before and after route rotation.

View file

@ -1,6 +1,6 @@
{
"name": "cultmesh-browser",
"version": "0.2.0",
"version": "0.3.0",
"description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.",
"type": "module",
"main": "dist/index.js",
@ -41,7 +41,7 @@
},
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"cultnet-ts": "^0.1.1"
"cultnet-ts": "^0.2.0"
},
"devDependencies": {
"@types/node": "^24.0.0",

View file

@ -12,6 +12,8 @@ import {
type CultNetSnapshotResponseRawMessage,
type CultMeshVerseCatalogRequestMessage,
type CultMeshVerseCatalogResponseMessage,
type CultMeshSessionAcceptedMessage,
type CultMeshSessionOpenMessage,
} from "cultnet-ts/contracts";
export interface CultMeshBrowserIdentity {
@ -21,11 +23,40 @@ export interface CultMeshBrowserIdentity {
export interface CultMeshBrowserRoute extends CultMeshBrowserIdentity {
endpoint: string;
generation?: string;
protocolId?: string;
protocolIds?: readonly string[];
priority?: number;
generation: string;
certificate?: CultMeshBrowserRouteCertificate;
}
export interface CultMeshBrowserP256PublicKey {
keyId: string;
x: string;
y: string;
}
export interface CultMeshBrowserRouteCertificate {
providerKey: CultMeshBrowserP256PublicKey;
odinKeyId: string;
issuedAtUnixMilliseconds: number;
expiresAtUnixMilliseconds: number;
signature: string;
}
export type CultMeshBrowserAuthorityTrustMode = "authenticated-remote" | "local-development";
export interface CultMeshBrowserAuthorityTrustPolicy {
mode: CultMeshBrowserAuthorityTrustMode;
odinRoots?: readonly CultMeshBrowserP256PublicKey[];
now?: () => number;
}
export interface CultMeshBrowserRendezvous {
resolve(identity: CultMeshBrowserIdentity): Promise<CultMeshBrowserRoute>;
resolve(
identity: CultMeshBrowserIdentity,
trust?: CultMeshBrowserAuthorityTrustPolicy,
): Promise<CultMeshBrowserRoute>;
}
export interface CultMeshBrowserOdinRendezvousOptions {
@ -73,13 +104,16 @@ export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous
if (this.#options.maxFrameBytes <= 0) throw new Error("Odin rendezvous maxFrameBytes must be positive.");
}
async resolve(identity: CultMeshBrowserIdentity): Promise<CultMeshBrowserRoute> {
async resolve(
identity: CultMeshBrowserIdentity,
trust: CultMeshBrowserAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [] },
): Promise<CultMeshBrowserRoute> {
requireText(identity.verseId, "verseId");
requireText(identity.authorityRuntimeId, "authorityRuntimeId");
const failures: Error[] = [];
for (const endpoint of this.#options.endpoints) {
try {
return await this.resolveFrom(endpoint, identity);
return await this.resolveFrom(endpoint, identity, trust);
} catch (error) {
failures.push(error instanceof Error ? error : new Error(String(error)));
}
@ -90,7 +124,11 @@ export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous
);
}
private resolveFrom(endpoint: string, identity: CultMeshBrowserIdentity): Promise<CultMeshBrowserRoute> {
private resolveFrom(
endpoint: string,
identity: CultMeshBrowserIdentity,
trust: CultMeshBrowserAuthorityTrustPolicy,
): Promise<CultMeshBrowserRoute> {
const socket = this.#options.socketFactory(endpoint);
socket.binaryType = "arraybuffer";
const messageId = this.#options.createId();
@ -141,7 +179,9 @@ export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous
return;
}
if (message.schemaVersion !== "cultmesh.verse_catalog_response.v0" || message.messageId !== messageId) return;
finish(undefined, selectOdinRoute(message, identity));
void selectOdinRoute(message, identity, trust)
.then(route => finish(undefined, route))
.catch(error => finish(error instanceof Error ? error : new Error(String(error))));
}).catch(error => finish(error instanceof Error ? error : new Error(String(error))));
};
socket.onerror = () => finish(new Error(`Odin WebSocket could not open '${endpoint}'.`));
@ -159,6 +199,8 @@ export interface CultMeshBrowserClientOptions extends CultMeshBrowserIdentity {
requestTimeoutMs?: number;
maxFrameBytes?: number;
createId?: () => string;
/** Consumer-owned authority roots. Remote sessions fail closed when omitted. */
trust?: CultMeshBrowserAuthorityTrustPolicy;
}
export interface CultMeshRawDocumentLeaseOptions {
@ -173,6 +215,7 @@ export interface CultMeshOperationOptions {
payloadSchema: string;
payload: unknown;
idempotencyKey?: string;
/** @deprecated The connected authority is the operation target. A different value is rejected. */
targetRuntimeId?: string;
}
@ -211,6 +254,7 @@ interface PendingSnapshot {
}
interface PendingOperation {
request: CultNetOperationRequestMessage;
resolve: (response: CultNetOperationResponseMessage) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
@ -293,6 +337,7 @@ export class CultMeshBrowserClient implements AsyncDisposable {
maxFrameBytes: options.maxFrameBytes ?? 4 * 1024 * 1024,
createId: options.createId ?? (() => crypto.randomUUID()),
socketFactory: options.socketFactory ?? (endpoint => new WebSocket(endpoint)),
trust: options.trust ?? { mode: "authenticated-remote", odinRoots: [] },
};
}
@ -338,6 +383,11 @@ export class CultMeshBrowserClient implements AsyncDisposable {
requireText(options.serviceId, "serviceId");
requireText(options.operation, "operation");
requireText(options.payloadSchema, "payloadSchema");
if (options.targetRuntimeId && options.targetRuntimeId !== this.identity.authorityRuntimeId) {
throw new Error(
`CultMesh operation target '${options.targetRuntimeId}' does not match connected authority '${this.identity.authorityRuntimeId}'.`,
);
}
await this.ensureConnected();
const messageId = options.idempotencyKey ?? this.#options.createId();
requireText(messageId, "operation idempotencyKey");
@ -353,22 +403,19 @@ export class CultMeshBrowserClient implements AsyncDisposable {
payloadEncoding: "messagepack-base64",
payload: bytesToBase64(encode(options.payload)),
sourceRuntimeId: this.runtimeId,
...(options.targetRuntimeId ? { targetRuntimeId: options.targetRuntimeId } : {}),
targetRuntimeId: this.identity.authorityRuntimeId,
};
const response = new Promise<CultNetOperationResponseMessage>((resolve, reject) => {
const timer = setTimeout(() => {
if (!this.#pendingOperations.delete(messageId)) return;
reject(new Error(`CultMesh operation '${messageId}' timed out.`));
}, this.#options.requestTimeoutMs);
this.#pendingOperations.set(messageId, { resolve, reject, timer });
this.#pendingOperations.set(messageId, { request, resolve, reject, timer });
});
try {
this.send(request);
} catch (error) {
const pending = this.#pendingOperations.get(messageId);
if (pending) clearTimeout(pending.timer);
this.#pendingOperations.delete(messageId);
throw error;
this.scheduleReconnect();
}
return response;
}
@ -466,6 +513,11 @@ export class CultMeshBrowserClient implements AsyncDisposable {
private connectSocket(): Promise<void> {
this.throwIfDisposed();
if (this.#state === "connected" && this.#socket?.readyState === 1) return Promise.resolve();
if (this.#reconnectTimer) {
clearTimeout(this.#reconnectTimer);
this.#reconnectTimer = undefined;
}
if (this.#connectPromise) return this.#connectPromise;
const generation = ++this.#socketGeneration;
this.setState(this.#socket ? "reconnecting" : "connecting");
@ -476,11 +528,12 @@ export class CultMeshBrowserClient implements AsyncDisposable {
}
private async openSocket(generation: number): Promise<void> {
const route = await this.#options.rendezvous.resolve(this.identity);
const route = await this.#options.rendezvous.resolve(this.identity, this.#options.trust);
if (route.verseId !== this.identity.verseId ||
route.authorityRuntimeId !== this.identity.authorityRuntimeId) {
throw new Error("CultMesh rendezvous returned a route for the wrong stable identity.");
}
await verifyAuthorityRoute(route, this.#options.trust);
const endpoint = new URL(route.endpoint);
if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") {
throw new Error(`CultMesh browser route must use ws:// or wss://, got '${route.endpoint}'.`);
@ -489,13 +542,74 @@ export class CultMeshBrowserClient implements AsyncDisposable {
socket.binaryType = "arraybuffer";
try {
await new Promise<void>((resolve, reject) => {
let opened = false;
let settled = false;
const handshakeId = this.#options.createId();
const clientNonce = randomNonce();
const sessionRequest: CultMeshSessionOpenMessage = {
schemaVersion: "cultmesh.session_open.v2",
messageId: handshakeId,
sourceRuntimeId: this.runtimeId,
verseId: this.identity.verseId,
authorityRuntimeId: this.identity.authorityRuntimeId,
protocolId: route.protocolId ?? "cultmesh.documents.v1",
routeGeneration: route.generation,
clientNonce,
};
const finish = (error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (error) reject(error);
else resolve();
};
const timer = setTimeout(
() => reject(new Error(`CultMesh WebSocket open timed out for '${route.endpoint}'.`)),
() => finish(new Error(
opened
? `CultMesh authority handshake timed out for '${route.endpoint}'.`
: `CultMesh WebSocket open timed out for '${route.endpoint}'.`,
)),
this.#options.connectTimeoutMs,
);
socket.onopen = () => { clearTimeout(timer); resolve(); };
socket.onerror = () => { clearTimeout(timer); reject(new Error(`CultMesh WebSocket could not open '${route.endpoint}'.`)); };
socket.onclose = () => { clearTimeout(timer); reject(new Error(`CultMesh WebSocket closed while opening '${route.endpoint}'.`)); };
socket.onopen = () => {
opened = true;
try {
sendSocketMessage(socket, {
schemaVersion: "cultnet.hello.v0",
runtimeId: this.runtimeId,
runtimeKind: "browser",
supportedMessageVersions: [
"cultmesh.session_open.v2",
"cultmesh.session_accepted.v2",
"cultnet.database_subscribe.v0",
"cultnet.database_unsubscribe.v0",
"cultnet.database_change_raw.v0",
"cultnet.operation_request.v0",
"cultnet.operation_response.v0",
],
}, this.#options.maxFrameBytes);
sendSocketMessage(socket, sessionRequest, this.#options.maxFrameBytes);
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
}
};
socket.onmessage = event => {
void decodeSocketMessage(event, this.#options.maxFrameBytes).then(message => {
if (message.schemaVersion === "cultnet.error.v0") {
finish(new Error(message.error));
return;
}
if (message.schemaVersion !== "cultmesh.session_accepted.v2" || message.messageId !== handshakeId) return;
void validateSessionAcceptance(
message,
sessionRequest,
route,
this.#options.trust,
).then(finish).catch(error => finish(error instanceof Error ? error : new Error(String(error))));
}).catch(error => finish(error instanceof Error ? error : new Error(String(error))));
};
socket.onerror = () => finish(new Error(`CultMesh WebSocket could not open '${route.endpoint}'.`));
socket.onclose = () => finish(new Error(`CultMesh WebSocket closed before proving authority at '${route.endpoint}'.`));
});
} catch (error) {
socket.close(1000, "CultMesh route open failed");
@ -511,19 +625,8 @@ export class CultMeshBrowserClient implements AsyncDisposable {
socket.onclose = () => this.handleClosed(generation);
this.setState("connected");
try {
this.send({
schemaVersion: "cultnet.hello.v0",
runtimeId: this.runtimeId,
runtimeKind: "browser",
supportedMessageVersions: [
"cultnet.database_subscribe.v0",
"cultnet.database_unsubscribe.v0",
"cultnet.database_change_raw.v0",
"cultnet.operation_request.v0",
"cultnet.operation_response.v0",
],
});
await Promise.all([...this.#leases.values()].map(lease => this.subscribe(lease)));
for (const pending of this.#pendingOperations.values()) this.send(pending.request);
} catch (error) {
if (this.#socket === socket) this.#socket = undefined;
socket.onclose = null;
@ -540,12 +643,7 @@ export class CultMeshBrowserClient implements AsyncDisposable {
clearTimeout(pending.timer);
pending.reject(error);
}
for (const pending of this.#pendingOperations.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.#pendingSnapshots.clear();
this.#pendingOperations.clear();
this.setState("reconnecting");
this.#reconnectTimer = setTimeout(() => {
this.#reconnectTimer = undefined;
@ -576,6 +674,15 @@ export class CultMeshBrowserClient implements AsyncDisposable {
case "cultnet.operation_response.v0": {
const pending = this.#pendingOperations.get(message.messageId);
if (!pending) return;
if (message.sourceRuntimeId !== this.identity.authorityRuntimeId) {
this.#pendingOperations.delete(message.messageId);
clearTimeout(pending.timer);
pending.reject(new Error(
`CultMesh operation response came from '${message.sourceRuntimeId ?? "unknown"}', expected '${this.identity.authorityRuntimeId}'.`,
));
this.#socket?.close(1008, "CultMesh authority identity changed");
return;
}
this.#pendingOperations.delete(message.messageId);
clearTimeout(pending.timer);
if (message.payloadSchema === cultNetOperationFailureSchema) {
@ -653,6 +760,15 @@ export class CultMeshBrowserClient implements AsyncDisposable {
for (const watcher of this.#stateWatchers) watcher(state);
}
private scheduleReconnect(): void {
if (this.#disposed || this.#reconnectTimer) return;
this.setState("reconnecting");
this.#reconnectTimer = setTimeout(() => {
this.#reconnectTimer = undefined;
void this.connectSocket().catch(() => this.scheduleReconnect());
}, this.#options.reconnectDelayMs);
}
private throwIfDisposed(): void {
if (this.#disposed) throw new Error("CultMesh browser client is disposed.");
}
@ -729,35 +845,230 @@ async function decodeSocketMessage(event: MessageEvent, maxFrameBytes: number):
return parseCultNetMessage(decode(payload));
}
function selectOdinRoute(
async function selectOdinRoute(
response: CultMeshVerseCatalogResponseMessage,
identity: CultMeshBrowserIdentity,
): CultMeshBrowserRoute {
const matchingVerses = response.verses.filter(verse =>
verse.verseId === identity.verseId &&
verse.authorityRuntimeIds.includes(identity.authorityRuntimeId));
trust: CultMeshBrowserAuthorityTrustPolicy,
): Promise<CultMeshBrowserRoute> {
const matchingVerses = response.verses.filter(verse => verse.verseId === identity.verseId);
if (matchingVerses.length !== 1) {
throw new Error(
`Odin returned ${matchingVerses.length} routes for Verse '${identity.verseId}' authority runtime '${identity.authorityRuntimeId}'.`,
);
}
const verse = matchingVerses[0];
const endpoints = verse.discoveryEndpoints.filter(endpoint => {
if (verse.authorityRoutes == null && verse.authorityRuntimeIds.length !== 1) {
throw new Error(
`Odin returned ambiguous legacy routes for Verse '${identity.verseId}'. Explicit authority route bindings are required.`,
);
}
if (verse.authorityRoutes == null && verse.authorityRuntimeIds[0] !== identity.authorityRuntimeId) {
throw new Error(
`Odin advertised no route for authority runtime '${identity.authorityRuntimeId}'.`,
);
}
const boundRoutes = verse.authorityRoutes ?? verse.discoveryEndpoints.map(endpoint => ({
authorityRuntimeId: verse.authorityRuntimeIds[0],
endpoint,
protocolIds: ["cultmesh.documents.v1"],
priority: 0,
generation: [verse.compatibility.transportVersion, verse.compatibility.rulesHash, endpoint].join(":"),
certificate: undefined,
}));
const routes = boundRoutes.filter(route =>
route.authorityRuntimeId === identity.authorityRuntimeId &&
route.protocolIds.includes("cultmesh.documents.v1") &&
(() => {
try {
requireWebSocketEndpoint(endpoint, "Odin provider route");
requireWebSocketEndpoint(route.endpoint, "Odin provider route");
return true;
} catch {
return false;
}
});
if (endpoints.length === 0) {
})()).sort((left, right) => left.priority - right.priority || left.endpoint.localeCompare(right.endpoint));
if (routes.length === 0) {
throw new Error(`Odin advertised no browser-compatible route for authority runtime '${identity.authorityRuntimeId}'.`);
}
return {
...identity,
endpoint: endpoints[0],
generation: [verse.compatibility.transportVersion, verse.compatibility.rulesHash, endpoints[0]].join(":"),
};
const failures: Error[] = [];
for (const route of routes) {
const candidate: CultMeshBrowserRoute = {
...identity,
endpoint: route.endpoint,
protocolId: "cultmesh.documents.v1",
protocolIds: route.protocolIds,
priority: route.priority,
generation: route.generation,
...(route.certificate ? { certificate: {
providerKey: {
keyId: route.certificate.providerKeyId,
x: route.certificate.providerPublicKeyX,
y: route.certificate.providerPublicKeyY,
},
odinKeyId: route.certificate.odinKeyId,
issuedAtUnixMilliseconds: route.certificate.issuedAtUnixMilliseconds,
expiresAtUnixMilliseconds: route.certificate.expiresAtUnixMilliseconds,
signature: route.certificate.signature,
} } : {}),
};
try {
await verifyAuthorityRoute(candidate, trust);
return candidate;
} catch (error) {
failures.push(error instanceof Error ? error : new Error(String(error)));
}
}
throw new AggregateError(
failures,
`Odin advertised no trusted browser route for authority runtime '${identity.authorityRuntimeId}'.`,
);
}
async function validateSessionAcceptance(
message: CultMeshSessionAcceptedMessage,
request: CultMeshSessionOpenMessage,
route: CultMeshBrowserRoute,
trust: CultMeshBrowserAuthorityTrustPolicy,
): Promise<Error | undefined> {
if (!message.accepted) return new Error(message.error ?? "CultMesh authority rejected the session.");
const protocolId = route.protocolId ?? "cultmesh.documents.v1";
if (message.verseId !== request.verseId ||
message.authorityRuntimeId !== request.authorityRuntimeId ||
message.protocolId !== protocolId ||
message.routeGeneration !== route.generation ||
message.clientNonce !== request.clientNonce) {
return new Error(
`CultMesh route proved '${message.verseId}/${message.authorityRuntimeId}/${message.protocolId}/${message.routeGeneration}', ` +
`expected '${request.verseId}/${request.authorityRuntimeId}/${protocolId}/${route.generation}'.`,
);
}
if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && !route.certificate) return undefined;
const providerKey = route.certificate?.providerKey;
if (!providerKey || message.providerKeyId !== providerKey.keyId || !message.providerSignature) {
return new Error("CultMesh authority did not prove possession of the Odin-certified provider key.");
}
if (!await verifyP256(providerKey, canonicalSession(request, route.endpoint), message.providerSignature)) {
return new Error("CultMesh provider session proof is invalid.");
}
return undefined;
}
async function verifyAuthorityRoute(
route: CultMeshBrowserRoute,
trust: CultMeshBrowserAuthorityTrustPolicy,
): Promise<void> {
const certificate = route.certificate;
if (!certificate) {
if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint)) return;
throw new Error("Remote CultMesh routes require an Odin-signed authority certificate.");
}
const endpoint = new URL(route.endpoint);
if (endpoint.protocol !== "wss:" && !(trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint))) {
throw new Error("Authenticated remote CultMesh browser routes require wss:// channel protection.");
}
const now = trust.now?.() ?? Date.now();
if (now < certificate.issuedAtUnixMilliseconds || now >= certificate.expiresAtUnixMilliseconds) {
throw new Error("The Odin route certificate is not currently valid.");
}
const root = trust.odinRoots?.find(candidate => candidate.keyId === certificate.odinKeyId);
if (!root) throw new Error(`Odin key '${certificate.odinKeyId}' is not trusted by this consumer.`);
if (!await verifyP256(root, canonicalRoute(route), certificate.signature)) {
throw new Error("The Odin route certificate signature is invalid.");
}
}
function canonicalRoute(route: CultMeshBrowserRoute): Uint8Array {
const certificate = route.certificate!;
return canonicalFields(
"gamecult.cultmesh.route-certificate.v1",
route.verseId,
route.authorityRuntimeId,
route.endpoint,
[...(route.protocolIds ?? [route.protocolId ?? "cultmesh.documents.v1"])].sort().join("\u001f"),
String(route.priority ?? 0),
route.generation,
certificate.providerKey.keyId,
certificate.providerKey.x,
certificate.providerKey.y,
certificate.odinKeyId,
String(certificate.issuedAtUnixMilliseconds),
String(certificate.expiresAtUnixMilliseconds),
);
}
function canonicalSession(request: CultMeshSessionOpenMessage, endpoint: string): Uint8Array {
return canonicalFields(
"gamecult.cultmesh.session-proof.v1",
request.clientNonce,
request.messageId,
request.sourceRuntimeId,
request.verseId,
request.authorityRuntimeId,
request.protocolId,
endpoint,
request.routeGeneration,
);
}
function canonicalFields(...values: string[]): Uint8Array {
const encoder = new TextEncoder();
const encoded = values.map(value => encoder.encode(value));
const total = encoded.reduce((sum, value) => sum + 4 + value.byteLength, 0);
const result = new Uint8Array(total);
const view = new DataView(result.buffer);
let offset = 0;
for (const value of encoded) {
view.setUint32(offset, value.byteLength, false);
offset += 4;
result.set(value, offset);
offset += value.byteLength;
}
return result;
}
async function verifyP256(
key: CultMeshBrowserP256PublicKey,
payload: Uint8Array,
signatureBase64: string,
): Promise<boolean> {
try {
const x = base64ToBytes(key.x);
const y = base64ToBytes(key.y);
const signature = base64ToBytes(signatureBase64);
if (x.byteLength !== 32 || y.byteLength !== 32 || signature.byteLength !== 64) return false;
const raw = new Uint8Array(65);
raw[0] = 4;
raw.set(x, 1);
raw.set(y, 33);
const publicKey = await crypto.subtle.importKey(
"raw",
raw.slice().buffer as ArrayBuffer,
{ name: "ECDSA", namedCurve: "P-256" },
false,
["verify"],
);
return await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
publicKey,
signature.slice().buffer as ArrayBuffer,
payload.slice().buffer as ArrayBuffer,
);
} catch {
return false;
}
}
function randomNonce(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return bytesToBase64(bytes);
}
function isLoopbackEndpoint(value: string): boolean {
try {
const host = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase();
return host === "localhost" || host === "127.0.0.1" || host === "::1";
} catch {
return false;
}
}
function requireText(value: string, field: string): void {

View file

@ -9,6 +9,8 @@ import {
type CultNetMessage,
type CultNetOperationRequestMessage,
type CultNetRawDocumentRecord,
type CultMeshVerseCatalogResponseMessage,
type CultMeshSessionOpenMessage,
} from "cultnet-ts/contracts";
import {
@ -18,9 +20,12 @@ import {
decodeCultNetOperationPayload,
decodeCultNetPayload,
type CultMeshBrowserRoute,
type CultMeshBrowserP256PublicKey,
type CultMeshBrowserSocket,
} from "../src/index.js";
const localTrust = { mode: "local-development" as const };
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;
@ -34,20 +39,87 @@ test("Odin rendezvous resolves stable authority-runtime identity and observes ro
assert.equal((await rendezvous.resolve({
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
})).endpoint, "ws://127.0.0.1:4050/mesh");
}, localTrust)).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",
authorityRuntimeId: "sample.counter-daemon",
})).endpoint, "ws://127.0.0.1:4060/mesh");
}, localTrust)).endpoint, "ws://127.0.0.1:4060/mesh");
await assert.rejects(
rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "other-runtime" }),
rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "other-runtime" }, localTrust),
/could not resolve/,
);
});
test("Odin rendezvous never selects another authority's faster endpoint", async () => {
const odin = new FakeOdin("ws://127.0.0.1:4050/mesh");
odin.decoyEndpoint = "ws://127.0.0.1:4049/decoy";
const rendezvous = new CultMeshBrowserOdinRendezvous({
endpoints: ["ws://127.0.0.1:4040/odin"],
runtimeId: "browser-route-binding-test",
socketFactory: () => odin.open(),
});
const route = await rendezvous.resolve({
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
}, localTrust);
assert.equal(route.endpoint, "ws://127.0.0.1:4050/mesh");
});
test("Odin rendezvous accepts one-authority legacy routes and rejects ambiguous legacy catalogs", async () => {
const odin = new FakeOdin("ws://127.0.0.1:4070/mesh");
odin.omitAuthorityRoutes = true;
const rendezvous = new CultMeshBrowserOdinRendezvous({
endpoints: ["ws://127.0.0.1:4040/odin"],
runtimeId: "browser-legacy-route-test",
socketFactory: () => odin.open(),
});
const route = await rendezvous.resolve({
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
}, localTrust);
assert.equal(route.endpoint, "ws://127.0.0.1:4070/mesh");
odin.decoyEndpoint = "ws://127.0.0.1:4069/decoy";
await assert.rejects(
rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "sample.counter-daemon" }, localTrust),
(error: unknown) =>
error instanceof AggregateError &&
error.errors.some(inner => inner instanceof Error && /ambiguous legacy routes/.test(inner.message)),
);
});
test("Odin rendezvous rejects a poisoned route without vetoing a certified fallback", async () => {
const signed = await createSignedRoute("wss://provider.example/mesh");
const odin = new FakeOdin(signed.route.endpoint);
odin.authorityRoutesOverride = [
{
authorityRuntimeId: signed.route.authorityRuntimeId,
endpoint: "wss://poison.example/mesh",
protocolIds: ["cultmesh.documents.v1"],
priority: 0,
generation: "poisoned",
},
routeForCatalog(signed.route),
];
const rendezvous = new CultMeshBrowserOdinRendezvous({
endpoints: ["ws://127.0.0.1:4040/odin"],
runtimeId: "browser-certified-fallback-test",
socketFactory: () => odin.open(),
});
const selected = await rendezvous.resolve(
{ verseId: signed.route.verseId, authorityRuntimeId: signed.route.authorityRuntimeId },
{ mode: "authenticated-remote", odinRoots: [signed.odinPublic] },
);
assert.equal(selected.endpoint, signed.route.endpoint);
});
test("browser client leases provider state, invokes an operation, and follows route replacement", async () => {
const first = new FakeProvider("ws://127.0.0.1:4101/mesh", 4);
const second = new FakeProvider("ws://127.0.0.1:4102/mesh", 9);
@ -57,6 +129,7 @@ test("browser client leases provider state, invokes an operation, and follows ro
verseId: route.verseId,
authorityRuntimeId: route.authorityRuntimeId,
runtimeId: "browser-test",
trust: localTrust,
rendezvous: { resolve: async () => route },
createId: () => `id-${++id}`,
socketFactory: endpoint => endpoint === first.route.endpoint ? first.open() : second.open(),
@ -103,6 +176,7 @@ test("browser client rejects wrong rendezvous identity and has no document write
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
runtimeId: "browser-test",
trust: localTrust,
rendezvous: {
resolve: async () => ({
...provider.route,
@ -117,18 +191,85 @@ test("browser client rejects wrong rendezvous identity and has no document write
assert.equal("setDocument" in CultMeshBrowserClient.prototype, false);
});
test("browser client rejects a route whose peer proves the wrong authority", async () => {
const provider = new FakeProvider("ws://127.0.0.1:4251/mesh", 1, false, "intruder-runtime");
await assert.rejects(
CultMeshBrowserClient.connect({
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
runtimeId: "browser-peer-proof-test",
trust: localTrust,
rendezvous: { resolve: async () => ({ ...provider.route, authorityRuntimeId: "sample.counter-daemon" }) },
socketFactory: () => provider.open(),
}),
/proved .*intruder-runtime.*expected .*sample.counter-daemon/,
);
});
test("browser client accepts an Odin-certified route only after provider nonce proof", async () => {
const fixture = await createSignedRoute("wss://provider.example/mesh");
const client = await CultMeshBrowserClient.connect({
...fixture.route,
runtimeId: "browser-authenticated-test",
rendezvous: { resolve: async () => fixture.route },
trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic] },
socketFactory: () => signedHandshakeSocket(fixture.route, fixture.providerPrivate),
});
assert.equal(client.state, "connected");
await client.dispose();
});
test("browser client rejects a credential-free authority impersonator", async () => {
const fixture = await createSignedRoute("wss://provider.example/mesh");
const impersonator = new FakeProvider("wss://provider.example/mesh", 1);
await assert.rejects(
CultMeshBrowserClient.connect({
...fixture.route,
runtimeId: "browser-impersonation-test",
rendezvous: { resolve: async () => fixture.route },
trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic] },
socketFactory: () => impersonator.open(),
}),
/did not prove possession/,
);
});
test("browser client rejects a mutated or expired signed route before opening a provider socket", async () => {
const fixture = await createSignedRoute("wss://provider.example/mesh");
let opened = false;
const connect = (route: CultMeshBrowserRoute, now?: () => number) => CultMeshBrowserClient.connect({
...route,
runtimeId: "browser-route-certificate-test",
rendezvous: { resolve: async () => route },
trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic], now },
socketFactory: () => {
opened = true;
return signedHandshakeSocket(route, fixture.providerPrivate);
},
});
await assert.rejects(connect({ ...fixture.route, endpoint: "wss://evil.example/mesh" }), /signature is invalid/);
assert.equal(opened, false);
await assert.rejects(connect(fixture.route, () => fixture.expiresAt), /not currently valid/);
assert.equal(opened, false);
});
test("browser client bounds unanswered provider operations", async () => {
const route = {
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
endpoint: "ws://127.0.0.1:4301/mesh",
generation: "route-4301",
};
const client = await CultMeshBrowserClient.connect({
...route,
runtimeId: "browser-timeout-test",
trust: localTrust,
rendezvous: { resolve: async () => route },
requestTimeoutMs: 5,
socketFactory: () => new FakeSocket(() => undefined, () => undefined),
socketFactory: () => handshakeOnlySocket(route),
});
await assert.rejects(
client.invoke({
@ -148,6 +289,7 @@ test("browser client rejects a correlated framework failure without waiting for
const client = await CultMeshBrowserClient.connect({
...provider.route,
runtimeId: "browser-failure-test",
trust: localTrust,
rendezvous: { resolve: async () => provider.route },
requestTimeoutMs: 60_000,
socketFactory: () => provider.open(),
@ -172,16 +314,153 @@ test("browser client rejects a correlated framework failure without waiting for
await client.dispose();
});
test("browser client replays one idempotent operation after route loss without duplicating its effect", async () => {
const firstRoute = localRoute("ws://127.0.0.1:4311/mesh", "generation-1");
const secondRoute = localRoute("ws://127.0.0.1:4312/mesh", "generation-2");
let route = firstRoute;
let effectCount = 0;
let dropFirstResponse = true;
const results = new Map<string, CultNetMessage>();
const open = () => {
let socket: FakeSocket;
socket = new FakeSocket(wire => {
const message = parseCultNetMessage(decode(wire));
if (message.schemaVersion === "cultmesh.session_open.v2") {
socket.deliver({
schemaVersion: "cultmesh.session_accepted.v2",
messageId: message.messageId,
accepted: true,
verseId: message.verseId,
authorityRuntimeId: message.authorityRuntimeId,
protocolId: message.protocolId,
routeGeneration: message.routeGeneration,
clientNonce: message.clientNonce,
providerKeyId: "",
providerSignature: "",
});
return;
}
if (message.schemaVersion !== "cultnet.operation_request.v0") return;
let response = results.get(message.messageId);
if (!response) {
effectCount++;
response = {
schemaVersion: "cultnet.operation_response.v0",
messageId: message.messageId,
serviceId: message.serviceId,
operation: message.operation,
status: "accepted",
payloadSchema: "sample.increment_receipt.v1",
payloadEncoding: "messagepack-base64",
payload: Buffer.from(encode({ count: effectCount })).toString("base64"),
sourceRuntimeId: "sample.counter-daemon",
};
results.set(message.messageId, response!);
}
if (dropFirstResponse) {
dropFirstResponse = false;
route = secondRoute;
socket.close(1012, "route lost after commit");
return;
}
socket.deliver(response!);
}, () => undefined);
return socket;
};
const client = await CultMeshBrowserClient.connect({
...firstRoute,
runtimeId: "browser-operation-replay-test",
rendezvous: { resolve: async () => route },
trust: localTrust,
reconnectDelayMs: 1,
requestTimeoutMs: 2_000,
socketFactory: () => open(),
});
const response = await client.invoke({
serviceId: "sample.counter",
operation: "increment",
payloadSchema: "sample.increment.v1",
payload: { amount: 1 },
idempotencyKey: "replay-once",
});
assert.deepEqual(decodeCultNetOperationPayload<{ count: number }>(response), { count: 1 });
assert.equal(effectCount, 1);
await client.dispose();
});
test("an early caller reconnect cancels the scheduled reconnect and keeps one live socket", async () => {
const provider = new FakeProvider("ws://127.0.0.1:4361/mesh", 2);
const client = await CultMeshBrowserClient.connect({
...provider.route,
runtimeId: "browser-single-reconnect-test",
rendezvous: { resolve: async () => provider.route },
trust: localTrust,
reconnectDelayMs: 40,
socketFactory: () => provider.open(),
});
const lease = await client.leaseRawDocument({
schemaId: "sample.counter_state.v1",
recordKey: "counter:main",
});
provider.dropConnections();
await client.invoke({
serviceId: "sample.counter",
operation: "increment",
payloadSchema: "sample.increment.v1",
payload: { amount: 1 },
});
await new Promise(resolve => setTimeout(resolve, 80));
assert.equal(provider.openCount, 2);
assert.equal(provider.liveSocketCount, 1);
assert.equal(provider.activeSubscriptionCount, 1);
lease.dispose();
await client.dispose();
});
test("browser client rejects an operation response from another runtime", async () => {
const provider = new FakeProvider(
"ws://127.0.0.1:4371/mesh",
1,
false,
"sample.counter-daemon",
"intruder-runtime",
);
const client = await CultMeshBrowserClient.connect({
...provider.route,
runtimeId: "browser-response-authority-test",
trust: localTrust,
rendezvous: { resolve: async () => provider.route },
socketFactory: () => provider.open(),
});
await assert.rejects(
client.invoke({
serviceId: "sample.counter",
operation: "increment",
payloadSchema: "sample.increment.v1",
payload: { amount: 1 },
}),
/response came from 'intruder-runtime'/,
);
await client.dispose();
});
test("browser client rejects oversized outbound schema messages", async () => {
const route = {
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
endpoint: "ws://127.0.0.1:4401/mesh",
generation: "route-4401",
};
await assert.rejects(
CultMeshBrowserClient.connect({
...route,
runtimeId: "browser-size-test",
trust: localTrust,
rendezvous: { resolve: async () => route },
maxFrameBytes: 8,
socketFactory: () => new FakeSocket(() => undefined, () => undefined),
@ -195,6 +474,7 @@ test("browser client reserves subscription identity while the initial lease open
const client = await CultMeshBrowserClient.connect({
...provider.route,
runtimeId: "browser-lease-race-test",
trust: localTrust,
rendezvous: { resolve: async () => provider.route },
socketFactory: () => provider.open(),
});
@ -220,8 +500,17 @@ class FakeProvider {
#count: number;
#subscriptions = new Map<FakeSocket, Map<string, CultNetDatabaseSubscribeMessage>>();
#rejectOperations: boolean;
#runtimeId: string;
#responseRuntimeId: string;
#openCount = 0;
constructor(endpoint: string, count: number, rejectOperations = false) {
constructor(
endpoint: string,
count: number,
rejectOperations = false,
runtimeId = "sample.counter-daemon",
responseRuntimeId = runtimeId,
) {
this.route = {
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
@ -230,6 +519,8 @@ class FakeProvider {
};
this.#count = count;
this.#rejectOperations = rejectOperations;
this.#runtimeId = runtimeId;
this.#responseRuntimeId = responseRuntimeId;
}
get activeSubscriptionCount(): number {
@ -238,7 +529,20 @@ class FakeProvider {
return count;
}
get openCount(): number {
return this.#openCount;
}
get liveSocketCount(): number {
return this.#subscriptions.size;
}
dropConnections(): void {
for (const socket of [...this.#subscriptions.keys()]) socket.close(1012, "route dropped");
}
open(): FakeSocket {
this.#openCount++;
const socket = new FakeSocket(
wire => this.receive(socket, wire),
() => this.#subscriptions.delete(socket),
@ -250,6 +554,9 @@ class FakeProvider {
private receive(socket: FakeSocket, wire: Uint8Array): void {
const message = parseCultNetMessage(decode(wire));
switch (message.schemaVersion) {
case "cultmesh.session_open.v2":
this.acceptSession(socket, message);
return;
case "cultnet.database_subscribe.v0":
this.subscribe(socket, message);
return;
@ -264,6 +571,21 @@ class FakeProvider {
}
}
private acceptSession(socket: FakeSocket, message: CultMeshSessionOpenMessage): void {
socket.deliver({
schemaVersion: "cultmesh.session_accepted.v2",
messageId: message.messageId,
accepted: true,
verseId: message.verseId,
authorityRuntimeId: this.#runtimeId,
protocolId: message.protocolId,
routeGeneration: message.routeGeneration,
clientNonce: message.clientNonce,
providerKeyId: "",
providerSignature: "",
});
}
private subscribe(socket: FakeSocket, message: CultNetDatabaseSubscribeMessage): void {
this.#subscriptions.get(socket)!.set(message.subscriptionId, message);
socket.deliver({
@ -274,6 +596,7 @@ class FakeProvider {
}
private invoke(socket: FakeSocket, message: CultNetOperationRequestMessage): void {
assert.equal(message.targetRuntimeId, this.#runtimeId);
if (this.#rejectOperations) {
socket.deliver({
schemaVersion: "cultnet.operation_response.v0",
@ -288,7 +611,7 @@ class FakeProvider {
message: "Expected payload schema 'sample.increment.v1'.",
})),
diagnostics: ["Request schema did not match."],
sourceRuntimeId: "sample.counter-daemon",
sourceRuntimeId: this.#responseRuntimeId,
});
return;
}
@ -314,7 +637,7 @@ class FakeProvider {
payloadSchema: "sample.increment_receipt.v1",
payloadEncoding: "messagepack-base64",
payload: bytesToBase64(encode({ count: this.#count })),
sourceRuntimeId: "sample.counter-daemon",
sourceRuntimeId: this.#responseRuntimeId,
});
}
@ -328,13 +651,16 @@ class FakeProvider {
storedAt: "2026-08-17T00:00:00Z",
payloadEncoding: "messagepack",
payload: encode({ count: this.#count }),
sourceRuntimeId: "sample.counter-daemon",
sourceRuntimeId: this.#runtimeId,
};
}
}
class FakeOdin {
providerEndpoint: string;
decoyEndpoint: string | undefined;
omitAuthorityRoutes = false;
authorityRoutesOverride: NonNullable<CultMeshVerseCatalogResponseMessage["verses"][number]["authorityRoutes"]> | undefined;
constructor(providerEndpoint: string) {
this.providerEndpoint = providerEndpoint;
@ -359,7 +685,25 @@ class FakeOdin {
optionalPluginIds: [],
},
discoveryEndpoints: [this.providerEndpoint],
authorityRuntimeIds: ["sample.counter-daemon"],
authorityRuntimeIds: this.decoyEndpoint
? ["sample.counter-daemon", "sample.decoy-daemon"]
: ["sample.counter-daemon"],
...(!this.omitAuthorityRoutes ? { authorityRoutes: this.authorityRoutesOverride ?? [
...(this.decoyEndpoint ? [{
authorityRuntimeId: "sample.decoy-daemon",
endpoint: this.decoyEndpoint,
protocolIds: ["cultmesh.documents.v1"],
priority: 0,
generation: "decoy-generation",
}] : []),
{
authorityRuntimeId: "sample.counter-daemon",
endpoint: this.providerEndpoint,
protocolIds: ["cultmesh.documents.v1"],
priority: 10,
generation: this.providerEndpoint,
},
] } : {}),
}],
});
}, () => undefined);
@ -367,6 +711,62 @@ class FakeOdin {
}
}
function routeForCatalog(route: CultMeshBrowserRoute): NonNullable<
CultMeshVerseCatalogResponseMessage["verses"][number]["authorityRoutes"]
>[number] {
return {
authorityRuntimeId: route.authorityRuntimeId,
endpoint: route.endpoint,
protocolIds: [...(route.protocolIds ?? [route.protocolId ?? "cultmesh.documents.v1"])],
priority: route.priority ?? 0,
generation: route.generation,
...(route.certificate ? {
certificate: {
providerKeyId: route.certificate.providerKey.keyId,
providerPublicKeyX: route.certificate.providerKey.x,
providerPublicKeyY: route.certificate.providerKey.y,
odinKeyId: route.certificate.odinKeyId,
issuedAtUnixMilliseconds: route.certificate.issuedAtUnixMilliseconds,
expiresAtUnixMilliseconds: route.certificate.expiresAtUnixMilliseconds,
signature: route.certificate.signature,
},
} : {}),
};
}
function handshakeOnlySocket(route: CultMeshBrowserRoute): FakeSocket {
let socket: FakeSocket;
socket = new FakeSocket(wire => {
const message = parseCultNetMessage(decode(wire));
if (message.schemaVersion !== "cultmesh.session_open.v2") return;
socket.deliver({
schemaVersion: "cultmesh.session_accepted.v2",
messageId: message.messageId,
accepted: true,
verseId: route.verseId,
authorityRuntimeId: route.authorityRuntimeId,
protocolId: message.protocolId,
routeGeneration: message.routeGeneration,
clientNonce: message.clientNonce,
providerKeyId: "",
providerSignature: "",
});
}, () => undefined);
return socket;
}
function localRoute(endpoint: string, generation: string): CultMeshBrowserRoute {
return {
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
endpoint,
protocolId: "cultmesh.documents.v1",
protocolIds: ["cultmesh.documents.v1"],
priority: 0,
generation,
};
}
class FakeSocket implements CultMeshBrowserSocket {
binaryType: BinaryType = "arraybuffer";
readyState = 0;
@ -407,6 +807,116 @@ class FakeSocket implements CultMeshBrowserSocket {
}
}
async function createSignedRoute(endpoint: string): Promise<{
route: CultMeshBrowserRoute;
odinPublic: CultMeshBrowserP256PublicKey;
providerPrivate: CryptoKey;
expiresAt: number;
}> {
const odin = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
const provider = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
const odinPublic = await exportPublic("odin-root-1", odin.publicKey);
const providerPublic = await exportPublic("provider-1", provider.publicKey);
const issuedAt = Date.now() - 1_000;
const expiresAt = Date.now() + 60_000;
const unsigned: CultMeshBrowserRoute = {
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
endpoint,
protocolId: "cultmesh.documents.v1",
protocolIds: ["cultmesh.documents.v1"],
priority: 0,
generation: "signed-generation-1",
certificate: {
providerKey: providerPublic,
odinKeyId: odinPublic.keyId,
issuedAtUnixMilliseconds: issuedAt,
expiresAtUnixMilliseconds: expiresAt,
signature: "",
},
};
const signature = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
odin.privateKey,
testCanonicalRoute(unsigned).slice().buffer as ArrayBuffer,
);
return {
route: { ...unsigned, certificate: { ...unsigned.certificate!, signature: toBase64(new Uint8Array(signature)) } },
odinPublic,
providerPrivate: provider.privateKey,
expiresAt,
};
}
function signedHandshakeSocket(route: CultMeshBrowserRoute, providerPrivate: CryptoKey): FakeSocket {
let socket: FakeSocket;
socket = new FakeSocket(wire => {
const message = parseCultNetMessage(decode(wire));
if (message.schemaVersion !== "cultmesh.session_open.v2") return;
void crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
providerPrivate,
testCanonicalSession(message, route.endpoint).slice().buffer as ArrayBuffer,
).then(signature => socket.deliver({
schemaVersion: "cultmesh.session_accepted.v2",
messageId: message.messageId,
accepted: true,
verseId: message.verseId,
authorityRuntimeId: message.authorityRuntimeId,
protocolId: message.protocolId,
routeGeneration: message.routeGeneration,
clientNonce: message.clientNonce,
providerKeyId: route.certificate!.providerKey.keyId,
providerSignature: toBase64(new Uint8Array(signature)),
}));
}, () => undefined);
return socket;
}
async function exportPublic(keyId: string, key: CryptoKey): Promise<CultMeshBrowserP256PublicKey> {
const jwk = await crypto.subtle.exportKey("jwk", key);
return { keyId, x: urlBase64ToBase64(jwk.x!), y: urlBase64ToBase64(jwk.y!) };
}
function testCanonicalRoute(route: CultMeshBrowserRoute): Uint8Array {
const certificate = route.certificate!;
return testCanonical(
"gamecult.cultmesh.route-certificate.v1", route.verseId, route.authorityRuntimeId, route.endpoint,
[...route.protocolIds!].sort().join("\u001f"), String(route.priority), route.generation,
certificate.providerKey.keyId, certificate.providerKey.x, certificate.providerKey.y,
certificate.odinKeyId, String(certificate.issuedAtUnixMilliseconds), String(certificate.expiresAtUnixMilliseconds),
);
}
function testCanonicalSession(message: CultMeshSessionOpenMessage, endpoint: string): Uint8Array {
return testCanonical(
"gamecult.cultmesh.session-proof.v1", message.clientNonce, message.messageId, message.sourceRuntimeId,
message.verseId, message.authorityRuntimeId, message.protocolId, endpoint, message.routeGeneration,
);
}
function testCanonical(...values: string[]): Uint8Array {
const chunks = values.map(value => new TextEncoder().encode(value));
const bytes = new Uint8Array(chunks.reduce((sum, chunk) => sum + 4 + chunk.length, 0));
const view = new DataView(bytes.buffer);
let offset = 0;
for (const chunk of chunks) {
view.setUint32(offset, chunk.length, false);
offset += 4;
bytes.set(chunk, offset);
offset += chunk.length;
}
return bytes;
}
function toBase64(bytes: Uint8Array): string {
return Buffer.from(bytes).toString("base64");
}
function urlBase64ToBase64(value: string): string {
return Buffer.from(value, "base64url").toString("base64");
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);

View file

@ -44,7 +44,7 @@
"dependencies": {
"@msgpack/msgpack": "^3.1.2",
"cultcache-ts": "^0.13.5",
"cultnet-ts": "^0.1.1"
"cultnet-ts": "^0.2.0"
},
"devDependencies": {
"@types/node": "^24.0.0",

View file

@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.session-accepted.schema.json",
"title": "CultMesh Session Accepted",
"type": "object",
"required": ["schemaVersion", "messageId", "accepted", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration", "clientNonce", "providerKeyId", "providerSignature"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultmesh.session_accepted.v2" },
"messageId": { "type": "string", "minLength": 1 },
"accepted": { "type": "boolean" },
"verseId": { "type": "string", "minLength": 1 },
"authorityRuntimeId": { "type": "string", "minLength": 1 },
"protocolId": { "type": "string", "minLength": 1 },
"routeGeneration": { "type": "string" },
"clientNonce": { "type": "string" },
"providerKeyId": { "type": "string" },
"providerSignature": { "type": "string" },
"error": { "type": "string", "minLength": 1 }
}
}

View file

@ -0,0 +1,18 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.session-open.schema.json",
"title": "CultMesh Session Open",
"type": "object",
"required": ["schemaVersion", "messageId", "sourceRuntimeId", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration", "clientNonce"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultmesh.session_open.v2" },
"messageId": { "type": "string", "minLength": 1 },
"sourceRuntimeId": { "type": "string", "minLength": 1 },
"verseId": { "type": "string", "minLength": 1 },
"authorityRuntimeId": { "type": "string", "minLength": 1 },
"protocolId": { "type": "string", "minLength": 1 },
"routeGeneration": { "type": "string" },
"clientNonce": { "type": "string", "minLength": 1 }
}
}

View file

@ -18,6 +18,33 @@
"optionalPluginIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }
}
},
"authorityRoute": {
"type": "object",
"required": ["authorityRuntimeId", "endpoint", "protocolIds", "priority", "generation"],
"additionalProperties": false,
"properties": {
"authorityRuntimeId": { "type": "string", "minLength": 1 },
"endpoint": { "type": "string", "minLength": 1 },
"protocolIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
"priority": { "type": "integer", "minimum": 0 },
"generation": { "type": "string", "minLength": 1 },
"certificate": { "$ref": "#/$defs/routeCertificate" }
}
},
"routeCertificate": {
"type": "object",
"required": ["providerKeyId", "providerPublicKeyX", "providerPublicKeyY", "odinKeyId", "issuedAtUnixMilliseconds", "expiresAtUnixMilliseconds", "signature"],
"additionalProperties": false,
"properties": {
"providerKeyId": { "type": "string", "minLength": 1 },
"providerPublicKeyX": { "type": "string", "minLength": 1 },
"providerPublicKeyY": { "type": "string", "minLength": 1 },
"odinKeyId": { "type": "string", "minLength": 1 },
"issuedAtUnixMilliseconds": { "type": "integer", "minimum": 0 },
"expiresAtUnixMilliseconds": { "type": "integer", "minimum": 1 },
"signature": { "type": "string", "minLength": 1 }
}
},
"verse": {
"type": "object",
"required": ["verseId", "displayName", "authorityModel", "compatibility", "discoveryEndpoints", "authorityRuntimeIds"],
@ -29,6 +56,7 @@
"compatibility": { "$ref": "#/$defs/compatibility" },
"discoveryEndpoints": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
"authorityRuntimeIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
"authorityRoutes": { "type": "array", "items": { "$ref": "#/$defs/authorityRoute" } },
"parentVerseId": { "type": "string", "minLength": 1 },
"description": { "type": "string" }
}

View file

@ -1,6 +1,6 @@
{
"name": "cultnet-ts",
"version": "0.1.1",
"version": "0.2.0",
"description": "CultNet-style typed direct-pipe messaging and state replication for TypeScript runtimes.",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View file

@ -30,6 +30,8 @@ import operationRequestSchema from "../contracts/cultnet.operation-request.schem
import operationResponseSchema from "../contracts/cultnet.operation-response.schema.json";
import verseCatalogRequestSchema from "../contracts/cultmesh.verse-catalog-request.schema.json";
import verseCatalogResponseSchema from "../contracts/cultmesh.verse-catalog-response.schema.json";
import sessionOpenSchema from "../contracts/cultmesh.session-open.schema.json";
import sessionAcceptedSchema from "../contracts/cultmesh.session-accepted.schema.json";
import ghostlightAgentStateSchema from "../contracts/ghostlight.agent-state.schema.json";
export type CultNetWireContract = "cultnet.schema.v0" | "gamecult.networking.v0";
@ -59,7 +61,9 @@ export type CultNetSchemaVersion =
| "cultnet.operation_request.v0"
| "cultnet.operation_response.v0"
| "cultmesh.verse_catalog_request.v0"
| "cultmesh.verse_catalog_response.v0";
| "cultmesh.verse_catalog_response.v0"
| "cultmesh.session_open.v2"
| "cultmesh.session_accepted.v2";
export type CultNetSchemaKind = "wire_message" | "document_payload" | "shared_contract";
export type CultNetRawPayloadEncoding = "messagepack";
@ -350,10 +354,55 @@ export interface CultMeshVerseDescriptorMessage {
compatibility: CultMeshVerseCompatibilityMessage;
discoveryEndpoints: string[];
authorityRuntimeIds: string[];
authorityRoutes?: CultMeshAuthorityRouteMessage[];
parentVerseId?: string;
description?: string;
}
export interface CultMeshAuthorityRouteMessage {
authorityRuntimeId: string;
endpoint: string;
protocolIds: string[];
priority: number;
generation: string;
certificate?: CultMeshRouteCertificateMessage;
}
export interface CultMeshRouteCertificateMessage {
providerKeyId: string;
providerPublicKeyX: string;
providerPublicKeyY: string;
odinKeyId: string;
issuedAtUnixMilliseconds: number;
expiresAtUnixMilliseconds: number;
signature: string;
}
export interface CultMeshSessionOpenMessage {
schemaVersion: "cultmesh.session_open.v2";
messageId: string;
sourceRuntimeId: string;
verseId: string;
authorityRuntimeId: string;
protocolId: string;
routeGeneration: string;
clientNonce: string;
}
export interface CultMeshSessionAcceptedMessage {
schemaVersion: "cultmesh.session_accepted.v2";
messageId: string;
accepted: boolean;
verseId: string;
authorityRuntimeId: string;
protocolId: string;
routeGeneration: string;
clientNonce: string;
providerKeyId: string;
providerSignature: string;
error?: string;
}
export interface CultMeshVerseCatalogRequestMessage {
schemaVersion: "cultmesh.verse_catalog_request.v0";
messageId: string;
@ -618,7 +667,9 @@ export type CultNetMessage =
| CultNetOperationRequestMessage
| CultNetOperationResponseMessage
| CultMeshVerseCatalogRequestMessage
| CultMeshVerseCatalogResponseMessage;
| CultMeshVerseCatalogResponseMessage
| CultMeshSessionOpenMessage
| CultMeshSessionAcceptedMessage;
const ajv = new Ajv2020({
allErrors: true,
@ -651,6 +702,8 @@ const CULTNET_MESSAGE_SCHEMAS = [
operationResponseSchema,
verseCatalogRequestSchema,
verseCatalogResponseSchema,
sessionOpenSchema,
sessionAcceptedSchema,
] as const;
const cultNetValidators = new Map<CultNetSchemaVersion, ValidateFunction>();
@ -1198,6 +1251,9 @@ function normalizeCultNetOptionalNulls(
}
}
return;
case "cultmesh.session_accepted.v2":
stripNullProperties(candidate, ["error"]);
return;
case "cultnet.schema_catalog_request.v0":
stripNullProperties(candidate, ["schemaIds", "kinds"]);
return;

View file

@ -84,10 +84,32 @@ test("CultNet validates the canonical CultMesh Verse catalog messages", () => {
},
discoveryEndpoints: ["wss://provider.example/cultmesh"],
authorityRuntimeIds: ["sample.counter-provider"],
authorityRoutes: [{
authorityRuntimeId: "sample.counter-provider",
endpoint: "wss://provider.example/cultmesh",
protocolIds: ["cultmesh.documents.v1"],
priority: 0,
generation: "provider-route-1",
}],
}],
});
assert.equal(response.schemaVersion, "cultmesh.verse_catalog_response.v0");
assert.equal(response.verses[0].authorityRuntimeIds[0], "sample.counter-provider");
assert.equal(response.verses[0].authorityRoutes![0].generation, "provider-route-1");
const session = parseCultNetMessage({
schemaVersion: "cultmesh.session_accepted.v2",
messageId: "session-1",
accepted: true,
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-provider",
protocolId: "cultmesh.documents.v1",
routeGeneration: "provider-route-1",
clientNonce: "bm9uY2U=",
providerKeyId: "provider-key-1",
providerSignature: "c2lnbmF0dXJl",
});
assert.equal(session.schemaVersion, "cultmesh.session_accepted.v2");
assert.throws(() => parseCultNetMessage({
...response,

View file

@ -4,11 +4,20 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EveRoot Condition="'$(EveRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\Eve</EveRoot>
<UsePackageArtifacts Condition="'$(UsePackageArtifacts)' == ''">false</UsePackageArtifacts>
<CultLibPackageVersion Condition="'$(CultLibPackageVersion)' == ''">1.0.35</CultLibPackageVersion>
<EveSurfacePackageVersion Condition="'$(EveSurfacePackageVersion)' == ''">0.3.3</EveSurfacePackageVersion>
<RestoreAdditionalProjectSources Condition="'$(PackageFeed)' != ''">$(PackageFeed)</RestoreAdditionalProjectSources>
</PropertyGroup>
<ItemGroup>
<ItemGroup Condition="'$(UsePackageArtifacts)' != 'true'">
<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>
<ItemGroup Condition="'$(UsePackageArtifacts)' == 'true'">
<PackageReference Include="GameCult.Mesh" Version="$(CultLibPackageVersion)" />
<PackageReference Include="GameCult.Networking.WebSockets" Version="$(CultLibPackageVersion)" />
<PackageReference Include="GameCult.Eve.Surface" Version="$(EveSurfacePackageVersion)" />
</ItemGroup>
</Project>

View file

@ -37,13 +37,30 @@ static async Task RunOdinAsync(Args arguments)
if (string.IsNullOrWhiteSpace(arguments.ProviderEndpoint))
throw new ArgumentException("The Odin fixture requires --provider-endpoint.");
using var catalog = new CultMeshVerseCatalog();
var routes = new List<CultMeshAuthorityRoute>
{
new(
arguments.AuthorityRuntimeId,
arguments.ProviderEndpoint,
[CultMeshProtocols.Documents.Value],
priority: 10,
generation: arguments.RouteGeneration)
};
if (!string.IsNullOrWhiteSpace(arguments.DecoyEndpoint))
{
routes.Add(new CultMeshAuthorityRoute(
arguments.DecoyAuthorityRuntimeId,
arguments.DecoyEndpoint,
[CultMeshProtocols.Documents.Value],
priority: 0,
generation: arguments.DecoyRouteGeneration));
}
catalog.Upsert(new CultMeshVerseDescriptor(
arguments.VerseId,
arguments.VerseName,
CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility(arguments.TransportVersion, arguments.RulesHash),
discoveryEndpoints: [arguments.ProviderEndpoint],
authorityRuntimeIds: [arguments.AuthorityRuntimeId],
authorityRoutes: routes,
description: "Local executable Odin fixture for the browser/network onboarding witness."));
await using var schemaServer = new CultNetWebSocketSchemaServer();
@ -97,6 +114,12 @@ static async Task RunProviderAsync(Args arguments)
await node.FlushAsync();
await using var schemaServer = new CultNetWebSocketSchemaServer();
using var identity = new CultMeshSessionIdentityServer(
schemaServer,
arguments.AuthorityRuntimeId,
[arguments.VerseId],
[CultMeshProtocols.Documents.Value],
[arguments.RouteGeneration]);
using var subscriptions = new CultNetDatabaseSubscriptionServer(schemaServer, node.Database);
using var operationGate = new SemaphoreSlim(1, 1);
using var operations = new CultNetOperationServer(schemaServer, arguments.AuthorityRuntimeId)
@ -175,6 +198,10 @@ static async Task RunHeadlessAsync(Args arguments)
CreateClient = CreateClient,
TransportVersion = arguments.TransportVersion
},
Sessions = new CultMeshSessionManagerOptions
{
Trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment)
},
Connectors =
[
new CultMeshUriSchemaTransportConnector(
@ -347,12 +374,16 @@ sealed class Args
public string StatePath { get; private init; } = "sample-counter.cc";
public string OdinEndpoint { get; private init; } = "";
public string ProviderEndpoint { get; private init; } = "";
public string DecoyEndpoint { 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-daemon";
public string DecoyAuthorityRuntimeId { get; private init; } = "sample.decoy-daemon";
public string TransportVersion { get; private init; } = "cultmesh.v0";
public string RulesHash { get; private init; } = "sample-counter-v1";
public string RouteGeneration { get; private init; } = "sample-counter-route-1";
public string DecoyRouteGeneration { get; private init; } = "sample-decoy-route-1";
public int ExpectedCount { get; private init; } = 2;
public int BenchmarkOperations { get; private init; } = 10_000;
@ -369,12 +400,16 @@ sealed class Args
StatePath = named.GetValueOrDefault("--state", "sample-counter.cc"),
OdinEndpoint = named.GetValueOrDefault("--odin", ""),
ProviderEndpoint = named.GetValueOrDefault("--provider-endpoint", ""),
DecoyEndpoint = named.GetValueOrDefault("--decoy-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-daemon"),
DecoyAuthorityRuntimeId = named.GetValueOrDefault("--decoy-authority-runtime-id", "sample.decoy-daemon"),
TransportVersion = named.GetValueOrDefault("--transport-version", "cultmesh.v0"),
RulesHash = named.GetValueOrDefault("--rules-hash", "sample-counter-v1"),
RouteGeneration = named.GetValueOrDefault("--route-generation", "sample-counter-route-1"),
DecoyRouteGeneration = named.GetValueOrDefault("--decoy-route-generation", "sample-decoy-route-1"),
ExpectedCount = named.TryGetValue("--expected-count", out var expectedCount)
? int.Parse(expectedCount)
: 2,

View file

@ -12,6 +12,9 @@ This is the real-process continuation of `samples/eve-two-runtime`:
without application transport or reconnect code;
- one separate local Odin fixture answers the canonical CultMesh Verse catalog
over binary CultNet WebSocket messages;
- one wrong-authority provider is deliberately advertised on a better-priority
route, and both clients must ignore it because the route is bound to another
runtime;
- the browser and C# clients speak the same binary CultNet schema-v0 WebSocket
messages through the authenticated host adapter.

View file

@ -32,6 +32,7 @@ try {
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
runtimeId: "sample.chromium",
trust: { mode: "local-development" },
rendezvous: new CultMeshBrowserOdinRendezvous({
endpoints: [odinEndpoint],
runtimeId: "sample.chromium.discovery",
@ -86,7 +87,11 @@ try {
window.__sampleCount = decodeCultNetPayload<{ count: number }>(counter.current!).count;
window.__sampleReady = true;
} catch (error) {
window.__sampleError = error instanceof Error ? error.stack || error.message : String(error);
const details = error instanceof AggregateError
? error.errors.map(inner => inner instanceof Error ? inner.stack || inner.message : String(inner)).join("\n---\n")
: "";
window.__sampleError = (error instanceof Error ? error.stack || error.message : String(error))
+ (details ? `\n${details}` : "");
host.textContent = window.__sampleError;
throw error;
}

View file

@ -30,7 +30,14 @@ if (-not (Test-Path -LiteralPath $packagePath)) {
"https://www.nuget.org/api/v2/package/Microsoft.Native.Quic.MsQuic.Schannel/$version"
if ($LASTEXITCODE -ne 0) { throw "MsQuic package download failed with exit code $LASTEXITCODE" }
}
if ((Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash -ne $expectedPackageSha256) {
$sha256 = [Security.Cryptography.SHA256]::Create()
try {
$packageBytes = [IO.File]::ReadAllBytes($packagePath)
$packageSha256 = [BitConverter]::ToString($sha256.ComputeHash($packageBytes)).Replace("-", "")
} finally {
$sha256.Dispose()
}
if ($packageSha256 -ne $expectedPackageSha256) {
throw "MsQuic package digest does not match the pinned release."
}
if (-not (Test-Path -LiteralPath (Join-Path $nativeRoot "include\msquic.h"))) {

View file

@ -1,6 +1,8 @@
param(
[string] $Configuration = "Release",
[string] $OutputDirectory = "artifacts\unity\org.gamecult.cultlib",
[string] $NuGetConfig = "",
[switch] $NoRestore,
[switch] $UpdateTemplate
)
@ -8,9 +10,11 @@ $ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$projectPath = Join-Path $repoRoot "src\GameCult.Mesh\GameCult.Mesh.csproj"
$webSocketProjectPath = Join-Path $repoRoot "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj"
$quicProjectPath = Join-Path $repoRoot "src\GameCult.Mesh.Quic.Native\GameCult.Mesh.Quic.Native.csproj"
$templateRoot = Join-Path $repoRoot "unity\org.gamecult.cultlib"
$publishRoot = Join-Path $repoRoot "artifacts\unity-publish"
$webSocketPublishRoot = Join-Path $repoRoot "artifacts\unity-websocket-publish"
$quicPublishRoot = Join-Path $repoRoot "artifacts\unity-quic-publish"
$quicNativeRoot = Join-Path $repoRoot "artifacts\unity-quic-native"
$outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) {
@ -26,15 +30,38 @@ if (Test-Path -LiteralPath $publishRoot) {
if (Test-Path -LiteralPath $quicPublishRoot) {
Remove-Item -LiteralPath $quicPublishRoot -Recurse -Force
}
if (Test-Path -LiteralPath $webSocketPublishRoot) {
Remove-Item -LiteralPath $webSocketPublishRoot -Recurse -Force
}
if (Test-Path -LiteralPath $outputRoot) {
Remove-Item -LiteralPath $outputRoot -Recurse -Force
}
dotnet publish $projectPath -c $Configuration -o $publishRoot
$unityPackageVersion = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).version
$publishArguments = @("publish", $projectPath, "-c", $Configuration, "-o", $publishRoot, "-p:CultLibPackageVersion=$unityPackageVersion")
if ($NoRestore) { $publishArguments += "--no-restore" }
if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
$publishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))"
}
& dotnet @publishArguments
if ($LASTEXITCODE -ne 0) {
throw "CultLib publish failed with exit code $LASTEXITCODE"
}
dotnet publish $quicProjectPath -c $Configuration -o $quicPublishRoot
$webSocketPublishArguments = @("publish", $webSocketProjectPath, "-c", $Configuration, "-f", "netstandard2.1", "-o", $webSocketPublishRoot, "-p:CultLibPackageVersion=$unityPackageVersion")
if ($NoRestore) { $webSocketPublishArguments += "--no-restore" }
if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
$webSocketPublishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))"
}
& dotnet @webSocketPublishArguments
if ($LASTEXITCODE -ne 0) {
throw "CultLib WebSocket publish failed with exit code $LASTEXITCODE"
}
$quicPublishArguments = @("publish", $quicProjectPath, "-c", $Configuration, "-o", $quicPublishRoot, "-p:CultLibPackageVersion=$unityPackageVersion")
if ($NoRestore) { $quicPublishArguments += "--no-restore" }
if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
$quicPublishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))"
}
& dotnet @quicPublishArguments
if ($LASTEXITCODE -ne 0) {
throw "CultLib native QUIC managed publish failed with exit code $LASTEXITCODE"
}
@ -54,6 +81,7 @@ $expectedAssemblies = @(
"GameCult.Mesh.dll",
"GameCult.Mesh.Quic.Native.dll",
"GameCult.Networking.dll",
"GameCult.Networking.WebSockets.dll",
"Isopoh.Cryptography.Argon2.dll",
"Isopoh.Cryptography.Blake2b.dll",
"Isopoh.Cryptography.SecureArray.dll",
@ -79,6 +107,9 @@ foreach ($assembly in Get-ChildItem -LiteralPath $publishRoot -Filter "*.dll") {
foreach ($assembly in Get-ChildItem -LiteralPath $quicPublishRoot -Filter "*.dll") {
$publishedByName[$assembly.Name] = $assembly
}
foreach ($assembly in Get-ChildItem -LiteralPath $webSocketPublishRoot -Filter "*.dll") {
$publishedByName[$assembly.Name] = $assembly
}
foreach ($assemblyName in $expectedAssemblies) {
if (-not $publishedByName.ContainsKey($assemblyName)) {
throw "CultLib Unity package is missing expected assembly: $assemblyName"
@ -103,6 +134,26 @@ New-Item -ItemType Directory -Force -Path (Join-Path $outputRoot "Third Party No
Copy-Item -LiteralPath (Join-Path $quicNativeRoot "MSQUIC-LICENSE.txt") `
-Destination (Join-Path $outputRoot "Third Party Notices\MSQUIC-LICENSE.txt")
$meshPackagePath = Join-Path $pluginRoot "GameCult.Mesh.dll"
$meshPackageVersion = [Reflection.AssemblyName]::GetAssemblyName($meshPackagePath).Version.ToString()
if ($meshPackageVersion -ne "$unityPackageVersion.0") {
throw "Packaged GameCult.Mesh.dll version '$meshPackageVersion' does not match package '$unityPackageVersion'."
}
$meshPackageMetadata = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($meshPackagePath))
foreach ($requiredType in @(
"GameCult.Mesh.CultMeshAuthorityTrustPolicy",
"GameCult.Mesh.CultMeshRouteCertificate",
"GameCult.Mesh.CultMeshHttpsContentTransportConnector")) {
if (-not $meshPackageMetadata.Contains($requiredType.Split('.')[-1])) {
throw "Packaged GameCult.Mesh.dll is missing required API '$requiredType'."
}
}
$webSocketPackageMetadata = [Text.Encoding]::UTF8.GetString(
[IO.File]::ReadAllBytes((Join-Path $pluginRoot "GameCult.Networking.WebSockets.dll")))
if (-not $webSocketPackageMetadata.Contains("CultNetWebSocketSchemaClient")) {
throw "Packaged WebSocket assembly is missing the Unity-compatible CultNet client."
}
if ($UpdateTemplate) {
$templatePluginRoot = Join-Path $templateRoot "Runtime\Plugins"
foreach ($assemblyName in $expectedAssemblies) {

View file

@ -1,6 +1,7 @@
param(
[string] $Configuration = "Release",
[string] $OutputDirectory = "artifacts\nuget",
[string] $PackageVersion = "",
[switch] $SkipConsumerSmoke
)
@ -18,6 +19,7 @@ $projects = @(
"src\GameCult.Caching.MessagePack.Analyzers\GameCult.Caching.MessagePack.Analyzers.csproj",
"src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj",
"src\GameCult.Networking\GameCult.Networking.csproj",
"src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj",
"src\GameCult.Mesh\GameCult.Mesh.csproj"
)
@ -27,8 +29,14 @@ if (Test-Path -LiteralPath $outputRoot) {
New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null
foreach ($project in $projects) {
dotnet pack (Join-Path $repoRoot $project) -c $Configuration -o $outputRoot `
--nologo --verbosity quiet -p:NoWarn=1591%3BCS8632
$packArguments = @(
"pack", (Join-Path $repoRoot $project), "-c", $Configuration, "-o", $outputRoot,
"--nologo", "--verbosity", "quiet", "-p:NoWarn=1591%3BCS8632"
)
if (-not [string]::IsNullOrWhiteSpace($PackageVersion)) {
$packArguments += "-p:CultLibPackageVersion=$PackageVersion"
}
& dotnet @packArguments
if ($LASTEXITCODE -ne 0) {
throw "NuGet pack failed for $project with exit code $LASTEXITCODE"
}
@ -48,6 +56,7 @@ $expectedPackages = @(
"GameCult.Caching.MessagePack.Analyzers.$version.nupkg",
"GameCult.Caching.MessagePack.$version.nupkg",
"GameCult.Networking.$version.nupkg",
"GameCult.Networking.WebSockets.$version.nupkg",
"GameCult.Mesh.$version.nupkg"
)
foreach ($package in $expectedPackages) {
@ -62,6 +71,7 @@ if (-not $SkipConsumerSmoke) {
Remove-Item -LiteralPath $smokeRoot -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $smokeRoot | Out-Null
$consumerPackages = Join-Path $smokeRoot "packages"
$projectDocument = @"
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
@ -69,6 +79,8 @@ if (-not $SkipConsumerSmoke) {
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<RestoreSources>$outputRoot;https://api.nuget.org/v3/index.json</RestoreSources>
<RestorePackagesPath>$consumerPackages</RestorePackagesPath>
<RestoreNoCache>true</RestoreNoCache>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GameCult.Mesh" Version="$version" />

View file

@ -14,12 +14,22 @@ import { chromium } from "playwright-core";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const eveFlag = process.argv.indexOf("--eve-root");
const eveRoot = resolve(eveFlag >= 0 ? process.argv[eveFlag + 1] : join(repoRoot, "..", "Eve"));
const packageFeedFlag = process.argv.indexOf("--package-feed");
const packageFeed = packageFeedFlag >= 0 ? resolve(process.argv[packageFeedFlag + 1]) : undefined;
const cultLibVersionFlag = process.argv.indexOf("--cultlib-package-version");
const cultLibPackageVersion = cultLibVersionFlag >= 0 ? process.argv[cultLibVersionFlag + 1] : undefined;
const eveSurfaceVersionFlag = process.argv.indexOf("--eve-surface-package-version");
const eveSurfacePackageVersion = eveSurfaceVersionFlag >= 0 ? process.argv[eveSurfaceVersionFlag + 1] : undefined;
const nugetConfigFlag = process.argv.indexOf("--nuget-config");
const nugetConfig = nugetConfigFlag >= 0 ? resolve(process.argv[nugetConfigFlag + 1]) : undefined;
const sampleRoot = join(repoRoot, "samples", "eve-browser-network");
const workRoot = await mkdtemp(join(tmpdir(), "cultmesh-browser-network-"));
const statePath = join(workRoot, "counter.cc");
const decoyStatePath = join(workRoot, "decoy-counter.cc");
const bundlePath = join(workRoot, "bundle.js");
const token = "sample-session";
let provider;
let decoyProvider;
let replacementProvider;
let odin;
let headless;
@ -41,27 +51,52 @@ try {
alias: {
"@gamecult/eve-contracts": join(eveRoot, "packages", "eve-contracts", "src", "index.ts"),
"@gamecult/eve-browser-lowering": join(eveRoot, "packages", "eve-browser-lowering", "src", "index.ts"),
"cultmesh-browser": join(repoRoot, "packages", "cultmesh-browser", "src", "index.ts"),
"cultnet-ts/contracts": join(repoRoot, "packages", "cultnet-ts", "src", "contracts.ts"),
},
logLevel: "warning",
});
const bundle = await readFile(bundlePath, "utf8");
assert.doesNotMatch(bundle, /(?:from\s*["']node:|require\(["']node:)/u, "browser bundle contains a Node builtin import");
await run("dotnet", [
const dotnetBuildArguments = [
"build",
join(sampleRoot, "EveBrowserNetworkSample.csproj"),
"-m:1",
"--verbosity", "quiet",
"-p:NoWarn=1591%3BCS8632",
`-p:EveRoot=${eveRoot}`,
]);
"-p:NuGetAudit=false",
"-p:RestoreIgnoreFailedSources=true",
];
if (nugetConfig) dotnetBuildArguments.push(`-p:RestoreConfigFile=${nugetConfig}`);
if (packageFeed) {
if (!cultLibPackageVersion || !eveSurfacePackageVersion) {
throw new Error("Package artifact builds require both CultLib and Eve surface package versions.");
}
dotnetBuildArguments.push(
"-p:UsePackageArtifacts=true",
`-p:PackageFeed=${packageFeed}`,
`-p:CultLibPackageVersion=${cultLibPackageVersion}`,
`-p:EveSurfacePackageVersion=${eveSurfacePackageVersion}`,
);
} else {
dotnetBuildArguments.push(`-p:EveRoot=${eveRoot}`, `-p:CultLibRoot=${repoRoot}`);
}
await run("dotnet", dotnetBuildArguments);
const providerPort = await freePort();
const decoyProviderPort = await freePort();
const replacementProviderPort = await freePort();
const odinPort = await freePort();
const httpPort = await freePort();
provider = await startProvider(providerPort);
const endpoint = await provider.waitFor("PROVIDER_READY ");
odin = await startOdin(odinPort, endpoint);
decoyProvider = await startProvider(decoyProviderPort, {
authorityRuntimeId: "sample.decoy-daemon",
routeGeneration: "sample-decoy-route-1",
statePath: decoyStatePath,
});
const decoyEndpoint = await decoyProvider.waitFor("PROVIDER_READY ");
odin = await startOdin(odinPort, endpoint, decoyEndpoint);
const odinEndpoint = await odin.waitFor("ODIN_READY ");
headless = startDotnet([
"headless", "--odin", odinEndpoint, "--verse-id", "sample.counter",
@ -91,7 +126,7 @@ try {
const restartedEndpoint = await replacementProvider.waitFor("PROVIDER_READY ");
assert.notEqual(restartedEndpoint, endpoint);
await stop(odin.process);
odin = await startOdin(odinPort, restartedEndpoint);
odin = await startOdin(odinPort, restartedEndpoint, decoyEndpoint);
assert.equal(await odin.waitFor("ODIN_READY "), odinEndpoint);
await stop(provider.process);
provider = replacementProvider;
@ -122,6 +157,7 @@ try {
console.log(JSON.stringify({
providerEndpoint: endpoint,
replacementProviderEndpoint: restartedEndpoint,
rejectedWrongAuthorityEndpoint: decoyEndpoint,
odinEndpoint,
browserRuntime: "chromium",
headlessRuntime: "csharp",
@ -136,21 +172,31 @@ try {
if (browser) await browser.close().catch(() => undefined);
if (headless) await stop(headless.process);
if (provider) await stop(provider.process);
if (decoyProvider) await stop(decoyProvider.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 });
}
async function startProvider(port) {
return startDotnet(["provider", "--port", String(port), "--state", statePath, "--token", token]);
async function startProvider(port, options = {}) {
return startDotnet([
"provider", "--port", String(port),
"--state", options.statePath ?? statePath,
"--authority-runtime-id", options.authorityRuntimeId ?? "sample.counter-daemon",
"--route-generation", options.routeGeneration ?? "sample-counter-route-1",
"--token", token,
]);
}
async function startOdin(port, providerEndpoint) {
async function startOdin(port, providerEndpoint, decoyEndpoint) {
return startDotnet([
"odin",
"--port", String(port),
"--provider-endpoint", providerEndpoint,
"--decoy-endpoint", decoyEndpoint,
"--decoy-authority-runtime-id", "sample.decoy-daemon",
"--decoy-route-generation", "sample-decoy-route-1",
"--token", token,
]);
}

View file

@ -25,43 +25,62 @@ if ($cultMeshClientSource -match 'ConnectAsync\s*\(\s*string\s+(endpointId|verse
$cultMeshClientSource -match 'Lease(Document|Collection)Async[^\(]*\(\s*string\s+(endpointId|verseId)') {
throw "CultMeshClient must not expose an ambiguous one-string session identity."
}
$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 authority runtime."
}
$dotnetPackageRoot = Join-Path ([IO.Path]::GetTempPath()) `
("eve-getting-started-nuget-" + [guid]::NewGuid().ToString("N"))
$sourceRevision = (& git -C $cultLibRoot rev-parse --short=12 HEAD).Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($sourceRevision)) {
throw "Could not determine the CultLib source revision for local package identity."
}
$verificationNonce = [guid]::NewGuid().ToString("N").Substring(0, 8)
$cultLibLocalVersion = "1.0.35-local.$sourceRevision.$verificationNonce"
$surfaceLocalVersion = "0.3.3-local.$sourceRevision.$verificationNonce"
try {
& (Join-Path $EveRoot "scripts\pack-dotnet-surface.ps1") `
-CultLibRoot $cultLibRoot `
-OutputDirectory $dotnetPackageRoot
-OutputDirectory $dotnetPackageRoot `
-CultLibPackageVersion $cultLibLocalVersion `
-SurfacePackageVersion $surfaceLocalVersion
if ($LASTEXITCODE -ne 0) {
throw "The clean .NET package-consumer checkpoint failed."
}
$cultLibPackage = Get-ChildItem -LiteralPath $dotnetPackageRoot -Filter "GameCult.Mesh.*.nupkg" |
Where-Object { $_.Name -notlike "*.snupkg" } |
Select-Object -First 1
$surfacePackage = Get-ChildItem -LiteralPath $dotnetPackageRoot -Filter "GameCult.Eve.Surface.*.nupkg" |
Where-Object { $_.Name -notlike "*.snupkg" } |
Select-Object -First 1
if (-not $cultLibPackage -or -not $surfacePackage) {
throw "The local package feed is missing CultMesh or Eve surface artifacts."
}
$cultLibVersion = [regex]::Match($cultLibPackage.Name, '^GameCult\.Mesh\.(.+)\.nupkg$').Groups[1].Value
$surfaceVersion = [regex]::Match($surfacePackage.Name, '^GameCult\.Eve\.Surface\.(.+)\.nupkg$').Groups[1].Value
& (Join-Path $PSScriptRoot "verify-eve-two-runtime-sample.ps1") `
-EveRoot $EveRoot `
-SkipDependencyInstall:$SkipDependencyInstall
if ($LASTEXITCODE -ne 0) {
throw "The clean package-consumer checkpoint failed."
}
$nodeCommand = Get-Command node -ErrorAction Stop
$playwrightCli = Join-Path $cultLibRoot "node_modules\playwright-core\cli.js"
if (-not (Test-Path -LiteralPath $playwrightCli -PathType Leaf)) {
throw "playwright-core is missing after dependency setup: $playwrightCli"
}
& $nodeCommand.Source (Join-Path $PSScriptRoot "verify-eve-browser-network.mjs") `
--eve-root $EveRoot `
--package-feed $dotnetPackageRoot `
--cultlib-package-version $cultLibVersion `
--eve-surface-package-version $surfaceVersion
if ($LASTEXITCODE -ne 0) {
throw "The real browser/C# network checkpoint failed."
}
Write-Host "Eve getting-started verification passed: clean .NET and TypeScript artifacts, DOM and headless convergence, real Chromium and C# clients, Odin discovery, authority-bound routes, receipts, persistence, and route replacement."
} finally {
if (Test-Path -LiteralPath $dotnetPackageRoot) {
Remove-Item -LiteralPath $dotnetPackageRoot -Recurse -Force
}
}
& (Join-Path $PSScriptRoot "verify-eve-two-runtime-sample.ps1") `
-EveRoot $EveRoot `
-SkipDependencyInstall:$SkipDependencyInstall
if ($LASTEXITCODE -ne 0) {
throw "The clean package-consumer checkpoint failed."
}
$nodeCommand = Get-Command node -ErrorAction Stop
$playwrightCli = Join-Path $cultLibRoot "node_modules\playwright-core\cli.js"
if (-not (Test-Path -LiteralPath $playwrightCli -PathType Leaf)) {
throw "playwright-core is missing after dependency setup: $playwrightCli"
}
& $nodeCommand.Source (Join-Path $PSScriptRoot "verify-eve-browser-network.mjs") `
--eve-root $EveRoot
if ($LASTEXITCODE -ne 0) {
throw "The real browser/C# network checkpoint failed."
}
Write-Host "Eve getting-started verification passed: clean .NET and TypeScript artifacts, DOM and headless convergence, real Chromium and C# clients, Odin discovery, receipts, persistence, and route replacement."

View file

@ -17,9 +17,18 @@ namespace GameCult.Caching.MessagePack;
/// </summary>
public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
{
private const string IndexedFormatVersion = "cultcache.store.v2.directory-indexed";
/// <summary>Fault-probe stage emitted before each immutable record page write.</summary>
public const string BeforeRecordPageWriteStage = "before-record-page-write";
/// <summary>Fault-probe stage emitted after all pages exist and before manifest finality.</summary>
public const string BeforeManifestCommitStage = "before-manifest-commit";
/// <summary>Read-probe stage emitted while the selected manifest generation is leased.</summary>
public const string AfterManifestReadStage = "after-manifest-read";
private const string LegacyIndexedFormatVersion = "cultcache.store.v2.directory-indexed";
private const string LegacyMetadataPageFormatVersion = "cultcache.store.v3.directory-immutable-pages";
private const string IndexedFormatVersion = "cultcache.store.v4.directory-content-addressed-pages";
private readonly FileInfo _manifestFile;
private readonly DirectoryInfo _recordDirectory;
private readonly object _mutationGate = new();
private readonly ConcurrentDictionary<string, bool> _dirtyKeys = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, bool> _deletedKeys = new(StringComparer.Ordinal);
private readonly HashSet<string> _hydratedKeys = new(StringComparer.Ordinal);
@ -27,6 +36,8 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
private Dictionary<string, CultPersistedRecord> _legacyInlineRecords = new(StringComparer.Ordinal);
private CultSchemaCatalogEntry[] _durableCatalog = Array.Empty<CultSchemaCatalogEntry>();
private bool _needsIndexUpgrade;
private bool _manifestUsesImmutablePages;
private bool _manifestUsesMetadataPages;
/// <summary>
/// Gets or sets the predicate that selects indexed record payloads for hydration.
@ -34,6 +45,12 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
/// </summary>
public Func<CultPersistedRecordMetadata, bool>? HydrationFilter { get; set; }
/// <summary>Optional deterministic fault/progress hook for durability tests and host diagnostics.</summary>
public Action<string, int>? FlushStageProbe { get; set; }
/// <summary>Optional deterministic read hook for generation-consistency tests and host diagnostics.</summary>
public Action<string>? ReadStageProbe { get; set; }
/// <summary>
/// Creates a paged MessagePack backing store.
/// </summary>
@ -53,8 +70,65 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
/// </summary>
public static string DefaultRecordDirectoryPath(string manifestPath) => manifestPath + ".records";
/// <summary>
/// Reads one complete persisted generation without deserializing documents through a
/// runtime registry. Indexed manifests are resolved only through the pages they name;
/// orphaned pages from older generations are never included.
/// </summary>
public CultPersistedStoreSnapshot ReadPersistedGeneration()
{
lock (_mutationGate)
using (AcquireCommitLease())
{
var manifest = ReadManifest();
var indexed = string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal) ||
string.Equals(manifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal) ||
string.Equals(manifest.FormatVersion, LegacyIndexedFormatVersion, StringComparison.Ordinal);
var records = indexed
? manifest.Records
.OrderBy(record => record.Key, StringComparer.Ordinal)
.Select(record => ReadPersistedRecordPage(record, manifest.FormatVersion, out _))
.ToArray()
: ReadLegacyPersistedGenerationRecords(manifest);
return new CultPersistedStoreSnapshot
{
FormatVersion = manifest.FormatVersion,
SchemaCatalog = manifest.SchemaCatalog.ToArray(),
Records = records
};
}
}
private CultPersistedRecord[] ReadLegacyPersistedGenerationRecords(CultPersistedStoreSnapshot manifest)
{
var records = manifest.Records
.Where(record => !string.IsNullOrWhiteSpace(record.Key))
.ToList();
_recordDirectory.Refresh();
if (_recordDirectory.Exists)
{
records.AddRange(_recordDirectory.EnumerateFiles("*.msgpack")
.OrderBy(file => file.Name, StringComparer.Ordinal)
.Select(file => CultDocumentMessagePackSerialization.DeserializePersistedRecord(
ReadAllBytesShared(file.FullName))));
}
return records
.Where(record => !string.IsNullOrWhiteSpace(record.Key))
.GroupBy(record => record.Key, StringComparer.Ordinal)
.Select(group => group.Last())
.OrderBy(record => record.Key, StringComparer.Ordinal)
.ToArray();
}
/// <inheritdoc />
public override void PullAll()
{
lock (_mutationGate)
PullAllCore();
}
private void PullAllCore()
{
var traceStartup = string.Equals(
Environment.GetEnvironmentVariable("CULTCACHE_TRACE_STARTUP_PHASES"),
@ -68,42 +142,50 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
startupPhase.Restart();
}
_recordDirectory.Refresh();
if (!File.Exists(_manifestFile.FullName) && !_recordDirectory.Exists)
{
SetLastSchemaMigrationReports(Array.Empty<CultSchemaMigrationReport>());
IsDirty = false;
return;
}
var manifest = ReadManifest();
Trace($"manifest records={manifest.Records.Length}");
_durableCatalog = manifest.SchemaCatalog;
var reports = new List<CultSchemaMigrationReport>();
var loaded = new Dictionary<string, CultStoredDocument>(StringComparer.Ordinal);
var indexed = string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal);
if (indexed)
using (AcquireCommitLease())
{
_needsIndexUpgrade = false;
_durableIndex = manifest.Records
.Where(record => !string.IsNullOrWhiteSpace(record.Key))
.GroupBy(record => record.Key, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
_recordDirectory.Refresh();
if (!File.Exists(_manifestFile.FullName) && !_recordDirectory.Exists)
{
SetLastSchemaMigrationReports(Array.Empty<CultSchemaMigrationReport>());
IsDirty = false;
return;
}
LoadIndexedRecords(manifest, loaded, reports);
Trace($"indexed-pages loaded={loaded.Count}");
}
else
{
// V1 manifests have no page index. Hydrate once to discover the durable keys;
// the next flush replaces this compatibility path with the indexed manifest.
_needsIndexUpgrade = true;
var legacyRecords = LoadLegacyRecords(manifest, loaded, reports);
_durableIndex = legacyRecords
.Where(record => !string.IsNullOrWhiteSpace(record.Key))
.GroupBy(record => record.Key, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => ToIndexRecord(group.Last()), StringComparer.Ordinal);
Trace($"legacy-pages loaded={loaded.Count}");
var manifest = ReadManifest();
ReadStageProbe?.Invoke(AfterManifestReadStage);
Trace($"manifest records={manifest.Records.Length}");
_durableCatalog = manifest.SchemaCatalog;
var indexed = string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal) ||
string.Equals(manifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal) ||
string.Equals(manifest.FormatVersion, LegacyIndexedFormatVersion, StringComparison.Ordinal);
if (indexed)
{
_manifestUsesImmutablePages = string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal);
_manifestUsesMetadataPages = string.Equals(manifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal);
_needsIndexUpgrade = !_manifestUsesImmutablePages;
_durableIndex = manifest.Records
.Where(record => !string.IsNullOrWhiteSpace(record.Key))
.GroupBy(record => record.Key, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
LoadIndexedRecords(manifest, loaded, reports);
Trace($"indexed-pages loaded={loaded.Count}");
}
else
{
// V1 manifests have no page index. Hydrate once to discover the durable keys;
// the next flush replaces this compatibility path with the indexed manifest.
_needsIndexUpgrade = true;
var legacyRecords = LoadLegacyRecords(manifest, loaded, reports);
_durableIndex = legacyRecords
.Where(record => !string.IsNullOrWhiteSpace(record.Key))
.GroupBy(record => record.Key, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => ToIndexRecord(group.Last()), StringComparer.Ordinal);
Trace($"legacy-pages loaded={loaded.Count}");
}
}
foreach (var pair in loaded)
@ -146,26 +228,46 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
/// <inheritdoc />
public override void PullSelected(Func<CultPersistedRecordMetadata, bool> selector)
{
if (selector == null) throw new ArgumentNullException(nameof(selector));
var manifest = ReadManifest();
if (!string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal))
{
PullAll();
return;
}
lock (_mutationGate)
PullSelectedCore(selector);
}
_durableCatalog = manifest.SchemaCatalog;
_durableIndex = manifest.Records
.Where(record => !string.IsNullOrWhiteSpace(record.Key))
.GroupBy(record => record.Key, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
var selected = manifest.Records
.Where(record => selector(ToMetadata(record)))
.OrderBy(record => record.Key, StringComparer.Ordinal)
.ToArray();
private void PullSelectedCore(Func<CultPersistedRecordMetadata, bool> selector)
{
if (selector == null) throw new ArgumentNullException(nameof(selector));
var fallbackToFullPull = false;
CultPersistedRecord[] selected = Array.Empty<CultPersistedRecord>();
var loaded = new Dictionary<string, CultStoredDocument>(StringComparer.Ordinal);
var reports = new List<CultSchemaMigrationReport>();
LoadRecordPages(selected, manifest.SchemaCatalog, loaded, reports);
using (AcquireCommitLease())
{
var manifest = ReadManifest();
ReadStageProbe?.Invoke(AfterManifestReadStage);
fallbackToFullPull = !string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal) &&
!string.Equals(manifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal) &&
!string.Equals(manifest.FormatVersion, LegacyIndexedFormatVersion, StringComparison.Ordinal);
if (!fallbackToFullPull)
{
_manifestUsesImmutablePages = string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal);
_manifestUsesMetadataPages = string.Equals(manifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal);
_needsIndexUpgrade = !_manifestUsesImmutablePages;
_durableCatalog = manifest.SchemaCatalog;
_durableIndex = manifest.Records
.Where(record => !string.IsNullOrWhiteSpace(record.Key))
.GroupBy(record => record.Key, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
selected = manifest.Records
.Where(record => selector(ToMetadata(record)))
.OrderBy(record => record.Key, StringComparer.Ordinal)
.ToArray();
LoadRecordPages(selected, manifest.SchemaCatalog, loaded, reports);
}
}
if (fallbackToFullPull)
{
PullAllCore();
return;
}
PublishSelected(loaded, selected.Select(record => record.Key).ToHashSet(StringComparer.Ordinal));
SetLastSchemaMigrationReports(reports);
IsDirty = !_dirtyKeys.IsEmpty || !_deletedKeys.IsEmpty;
@ -174,29 +276,88 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
/// <inheritdoc />
public override bool ContainsDurableRecord(CultRecordKey key)
{
return _durableIndex.ContainsKey(key.Value) || base.ContainsDurableRecord(key);
lock (_mutationGate)
return _durableIndex.ContainsKey(key.Value) || base.ContainsDurableRecord(key);
}
/// <inheritdoc />
public override void Push(CultStoredDocument entry)
{
Entries[entry.Key.Value] = entry;
_dirtyKeys[entry.Key.Value] = true;
_deletedKeys.TryRemove(entry.Key.Value, out _);
IsDirty = true;
lock (_mutationGate)
{
Entries[entry.Key.Value] = entry;
_dirtyKeys[entry.Key.Value] = true;
_deletedKeys.TryRemove(entry.Key.Value, out _);
IsDirty = true;
}
}
/// <inheritdoc />
public override void Delete(CultStoredDocument entry)
{
Entries.TryRemove(entry.Key.Value, out _);
_dirtyKeys.TryRemove(entry.Key.Value, out _);
_deletedKeys[entry.Key.Value] = true;
IsDirty = true;
lock (_mutationGate)
{
Entries.TryRemove(entry.Key.Value, out _);
_dirtyKeys.TryRemove(entry.Key.Value, out _);
_deletedKeys[entry.Key.Value] = true;
IsDirty = true;
}
}
/// <inheritdoc />
public override void CommitBatch(
IReadOnlyCollection<CultStoredDocument> upserts,
IReadOnlyCollection<CultStoredDocument> deletes,
bool soft)
{
lock (_mutationGate)
{
var previousEntries = Entries.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
var previousDirtyKeys = _dirtyKeys.Keys.ToArray();
var previousDeletedKeys = _deletedKeys.Keys.ToArray();
var wasDirty = IsDirty;
try
{
foreach (var entry in deletes)
{
Entries.TryRemove(entry.Key.Value, out _);
_dirtyKeys.TryRemove(entry.Key.Value, out _);
_deletedKeys[entry.Key.Value] = true;
}
foreach (var entry in upserts)
{
Entries[entry.Key.Value] = entry;
_dirtyKeys[entry.Key.Value] = true;
_deletedKeys.TryRemove(entry.Key.Value, out _);
}
IsDirty = true;
PushAllCore(soft);
}
catch
{
Entries.Clear();
foreach (var pair in previousEntries)
Entries[pair.Key] = pair.Value;
_dirtyKeys.Clear();
foreach (var key in previousDirtyKeys)
_dirtyKeys[key] = true;
_deletedKeys.Clear();
foreach (var key in previousDeletedKeys)
_deletedKeys[key] = true;
IsDirty = wasDirty;
throw;
}
}
}
/// <inheritdoc />
public override void PushAll(bool soft = false)
{
lock (_mutationGate)
PushAllCore(soft);
}
private void PushAllCore(bool soft)
{
if (!IsDirty && !_needsIndexUpgrade)
{
@ -205,15 +366,17 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
Directory.CreateDirectory(_manifestFile.DirectoryName!);
Directory.CreateDirectory(_recordDirectory.FullName);
using var commitLease = AcquireCommitLease();
var currentManifest = ReadManifest();
var currentIndex = string.Equals(currentManifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal)
var currentManifestIsIndexed = string.Equals(currentManifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal) ||
string.Equals(currentManifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal) ||
string.Equals(currentManifest.FormatVersion, LegacyIndexedFormatVersion, StringComparison.Ordinal);
var currentManifestUsesImmutablePages = string.Equals(currentManifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal);
var currentManifestUsesMetadataPages = string.Equals(currentManifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal);
var currentIndex = currentManifestIsIndexed
? currentManifest.Records.ToDictionary(record => record.Key, record => record, StringComparer.Ordinal)
: new Dictionary<string, CultPersistedRecord>(_durableIndex, StringComparer.Ordinal);
var precommitIndex = currentIndex.Values
.Select(ToIndexRecord)
.OrderBy(record => record.Key, StringComparer.Ordinal)
.ToArray();
foreach (var key in _deletedKeys.Keys)
currentIndex.Remove(key);
foreach (var key in _dirtyKeys.Keys)
@ -233,18 +396,10 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
.Where(entry => usedSchemaIds.Contains(entry.SchemaId))
.OrderBy(entry => entry.SchemaName, StringComparer.Ordinal)
.ToArray();
var precommitCatalog = currentManifest.SchemaCatalog
.Concat(targetCatalog)
.GroupBy(entry => entry.SchemaId, StringComparer.Ordinal)
.Select(group => group.Last())
.OrderBy(entry => entry.SchemaName, StringComparer.Ordinal)
.ToArray();
WriteManifest(precommitCatalog, precommitIndex);
var keysToWrite = (_needsIndexUpgrade ? Entries.Keys : _dirtyKeys.Keys)
.OrderBy(value => value, StringComparer.Ordinal)
.ToArray();
var pageWriteIndex = 0;
foreach (var key in keysToWrite)
{
if (!Entries.TryGetValue(key, out var stored))
@ -257,31 +412,56 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
document,
stored.Descriptor.DocumentType,
Registry));
WriteFileAtomically(RecordPath(key), CultDocumentMessagePackSerialization.SerializePersistedRecord(record));
var pagePayload = CultDocumentMessagePackSerialization.SerializePersistedRecord(record);
var indexRecord = ToContentAddressedIndexRecord(record, pagePayload);
currentIndex[key] = indexRecord;
FlushStageProbe?.Invoke(BeforeRecordPageWriteStage, pageWriteIndex++);
WriteFileAtomically(
ContentAddressedRecordPath(indexRecord),
pagePayload);
}
if (!currentManifestUsesImmutablePages)
{
foreach (var metadata in currentManifest.Records
.Where(record => !_deletedKeys.ContainsKey(record.Key) && !_dirtyKeys.ContainsKey(record.Key))
.OrderBy(record => record.Key, StringComparer.Ordinal))
{
var sourcePath = currentManifestUsesMetadataPages
? MetadataRecordPath(metadata)
: LegacyRecordPath(metadata.Key);
if (!File.Exists(sourcePath))
continue;
var pagePayload = ReadAllBytesShared(sourcePath);
var indexRecord = ToContentAddressedIndexRecord(metadata, pagePayload);
currentIndex[metadata.Key] = indexRecord;
var targetPath = ContentAddressedRecordPath(indexRecord);
if (!File.Exists(targetPath))
WriteFileAtomically(targetPath, pagePayload);
}
}
foreach (var pair in _legacyInlineRecords.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
if (_deletedKeys.ContainsKey(pair.Key) || _dirtyKeys.ContainsKey(pair.Key))
continue;
var pagePayload = CultDocumentMessagePackSerialization.SerializePersistedRecord(pair.Value);
var indexRecord = ToContentAddressedIndexRecord(pair.Value, pagePayload);
currentIndex[pair.Key] = indexRecord;
WriteFileAtomically(
RecordPath(pair.Key),
CultDocumentMessagePackSerialization.SerializePersistedRecord(pair.Value));
}
foreach (var key in _deletedKeys.Keys.OrderBy(value => value, StringComparer.Ordinal))
{
var path = RecordPath(key);
if (File.Exists(path))
{
File.Delete(path);
}
ContentAddressedRecordPath(indexRecord),
pagePayload);
}
FlushStageProbe?.Invoke(BeforeManifestCommitStage, pageWriteIndex);
WriteManifest(targetCatalog, currentIndex.Values
.OrderBy(record => record.Key, StringComparer.Ordinal)
.ToArray());
_manifestUsesImmutablePages = true;
_manifestUsesMetadataPages = false;
DeleteUnreferencedRecordPages(currentIndex.Values);
_durableCatalog = targetCatalog;
_durableIndex = currentIndex;
_legacyInlineRecords.Clear();
@ -367,13 +547,11 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
{
var started = tracePages ? Stopwatch.GetTimestamp() : 0L;
var metadata = records[index];
var path = RecordPath(metadata.Key);
if (!File.Exists(path))
return;
var pagePayload = ReadAllBytesShared(path);
var record = CultDocumentMessagePackSerialization.DeserializePersistedRecord(pagePayload);
if (!string.Equals(record.Key, metadata.Key, StringComparison.Ordinal))
throw new InvalidDataException($"Record page '{path}' contains key '{record.Key}', expected '{metadata.Key}'.");
var record = ReadPersistedRecordPage(
metadata,
_manifestUsesImmutablePages,
_manifestUsesMetadataPages,
out var pagePayload);
var catalog = ResolveLegacyUncataloguedRecordCatalog(record, catalogEntries);
recordReports[index] = Registry.ResolvePersistedSchemaReport(record.SchemaId, catalog);
storedRecords[index] = ToStoredDocument(
@ -509,6 +687,16 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
Payload = Array.Empty<byte>()
};
private static CultPersistedRecord ToContentAddressedIndexRecord(
CultPersistedRecord record,
byte[] pagePayload) => new()
{
Key = record.Key,
SchemaId = record.SchemaId,
StoredAt = record.StoredAt,
Payload = HashPayload(pagePayload)
};
private CultPersistedStoreSnapshot ReadManifest()
{
if (File.Exists(_manifestFile.FullName))
@ -607,7 +795,82 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
: "";
}
private string RecordPath(string key) => Path.Combine(_recordDirectory.FullName, $"{HashKey(key)}.msgpack");
private string LegacyRecordPath(string key) => Path.Combine(_recordDirectory.FullName, $"{HashKey(key)}.msgpack");
private string MetadataRecordPath(CultPersistedRecord metadata) => Path.Combine(
_recordDirectory.FullName,
$"{HashKey(metadata.Key + "\0" + metadata.SchemaId + "\0" + metadata.StoredAt)}.msgpack");
private string ContentAddressedRecordPath(CultPersistedRecord metadata)
{
if (metadata.Payload == null || metadata.Payload.Length != 32)
throw new InvalidDataException($"Record '{metadata.Key}' has no committed SHA-256 page identity.");
var hash = BitConverter.ToString(metadata.Payload).Replace("-", string.Empty).ToLowerInvariant();
return Path.Combine(_recordDirectory.FullName, $"{hash}.msgpack");
}
private static byte[] HashPayload(byte[] payload)
{
using var sha256 = SHA256.Create();
return sha256.ComputeHash(payload);
}
private void DeleteUnreferencedRecordPages(IEnumerable<CultPersistedRecord> durableRecords)
{
var referenced = durableRecords
.Select(ContentAddressedRecordPath)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var file in _recordDirectory.EnumerateFiles("*.msgpack"))
{
if (referenced.Contains(file.FullName))
continue;
try
{
File.Delete(file.FullName);
}
catch (IOException)
{
// An orphan is harmless: no committed manifest references it.
}
catch (UnauthorizedAccessException)
{
// Cleanup is best effort and never changes the committed generation.
}
}
}
private CultPersistedRecord ReadPersistedRecordPage(
CultPersistedRecord metadata,
string formatVersion,
out byte[] pagePayload) =>
ReadPersistedRecordPage(
metadata,
string.Equals(formatVersion, IndexedFormatVersion, StringComparison.Ordinal),
string.Equals(formatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal),
out pagePayload);
private CultPersistedRecord ReadPersistedRecordPage(
CultPersistedRecord metadata,
bool usesContentAddressedPages,
bool usesMetadataPages,
out byte[] pagePayload)
{
var path = usesContentAddressedPages
? ContentAddressedRecordPath(metadata)
: usesMetadataPages
? MetadataRecordPath(metadata)
: LegacyRecordPath(metadata.Key);
if (!File.Exists(path))
throw new InvalidDataException($"Committed record page '{path}' is missing from the selected manifest generation.");
pagePayload = ReadAllBytesShared(path);
if (usesContentAddressedPages &&
!HashPayload(pagePayload).SequenceEqual(metadata.Payload))
throw new InvalidDataException($"Record page '{path}' does not match its committed content hash.");
var record = CultDocumentMessagePackSerialization.DeserializePersistedRecord(pagePayload);
if (!string.Equals(record.Key, metadata.Key, StringComparison.Ordinal))
throw new InvalidDataException($"Record page '{path}' contains key '{record.Key}', expected '{metadata.Key}'.");
return record;
}
private static string HashKey(string key)
{
@ -662,6 +925,30 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
return buffer.ToArray();
}
private FileStream AcquireCommitLease()
{
Directory.CreateDirectory(_recordDirectory.FullName);
var lockPath = Path.Combine(_recordDirectory.FullName, ".commit.lock");
var started = Stopwatch.StartNew();
while (true)
{
try
{
return new FileStream(
lockPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None,
bufferSize: 1,
FileOptions.WriteThrough);
}
catch (IOException) when (started.Elapsed < TimeSpan.FromSeconds(30))
{
Thread.Sleep(10);
}
}
}
private static void ReplaceExistingFile(string sourcePath, string destinationPath)
{
for (var attempt = 0; attempt < 3; attempt++)
@ -681,6 +968,6 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
}
}
File.Copy(sourcePath, destinationPath, overwrite: true);
File.Replace(sourcePath, destinationPath, null);
}
}

View file

@ -39,6 +39,21 @@ These are **not** domain fields and should not be exposed on app-facing types:
Those belong to the CultCache persistence layer.
## Transaction Visibility
An authoritative multi-record change uses `CultCache.ExecuteTransactionAsync`.
The executing async flow sees its buffered overlay while staging; other readers
and all observers continue to see the prior committed generation. The backing
store commits the complete batch before the live cache swaps to it. Observer
notifications run only after the transaction context and commit lock have been
released, so an observer-triggered write begins a separate transaction.
An exception before durable finality discards the overlay. It cannot become
visible through a later flush. A transaction currently permits at most one
durable backing store because independent stores cannot provide one atomic
commit boundary. Applications that require authoritative writes may configure
`CultNetDatabase` to reject record-at-a-time writes outside this primitive.
## Canonical Store Shape
A persisted CultCache file should contain:

View file

@ -7,6 +7,7 @@ using System.Reflection;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Logging;
using R3;
@ -1278,6 +1279,9 @@ namespace GameCult.Caching
private readonly ConditionalWeakTable<object, DocumentHandleBox> _documentHandles = new();
private readonly Subject<object> _changes = new();
private readonly CultCacheSoaStore _soa = new();
private readonly SemaphoreSlim _transactionGate = new(1, 1);
private readonly AsyncLocal<CultCacheTransaction?> _ambientTransaction = new();
private readonly object _stateGate = new();
private ILogger _logger = new NullLogger();
private bool _hasUnflushedMutations;
@ -1348,14 +1352,29 @@ namespace GameCult.Caching
/// <summary>
/// Gets all document instances currently held by the cache.
/// </summary>
public IEnumerable<object> AllEntries => _entries.Values.Select(entry => entry.Document);
public IEnumerable<object> AllEntries
{
get
{
lock (_stateGate)
return VisibleStoredDocuments().Select(entry => entry.Document).ToArray();
}
}
/// <summary>
/// Gets all stored document records currently held by the cache.
/// </summary>
public IEnumerable<CultStoredDocument> AllStoredDocuments =>
_entries.Values.OrderBy(entry => entry.Descriptor.SchemaName, StringComparer.Ordinal)
.ThenBy(entry => entry.Key.Value, StringComparer.Ordinal);
public IEnumerable<CultStoredDocument> AllStoredDocuments
{
get
{
lock (_stateGate)
return VisibleStoredDocuments()
.OrderBy(entry => entry.Descriptor.SchemaName, StringComparer.Ordinal)
.ThenBy(entry => entry.Key.Value, StringComparer.Ordinal)
.ToArray();
}
}
/// <summary>
/// Gets the document registry used by this cache.
@ -1427,13 +1446,22 @@ namespace GameCult.Caching
/// </summary>
public async Task PullAllBackingStoresAsync()
{
foreach (var store in _backingStores)
{
store.PullAll();
}
if (_ambientTransaction.Value != null)
throw new InvalidOperationException(
"CultCache hydration cannot run inside a mutation transaction; hydrate before opening the commit scope.");
_hasUnflushedMutations = _backingStores.Any(store => store.IsDirty);
await Task.CompletedTask;
await _transactionGate.WaitAsync().ConfigureAwait(false);
try
{
foreach (var store in _backingStores)
store.PullAll();
_hasUnflushedMutations = _backingStores.Any(store => store.IsDirty);
}
finally
{
_transactionGate.Release();
}
}
/// <summary>
@ -1443,13 +1471,22 @@ namespace GameCult.Caching
public async Task PullBackingStoreRecordsAsync(Func<CultPersistedRecordMetadata, bool> selector)
{
if (selector == null) throw new ArgumentNullException(nameof(selector));
foreach (var store in _backingStores)
{
store.PullSelected(selector);
}
if (_ambientTransaction.Value != null)
throw new InvalidOperationException(
"CultCache hydration cannot run inside a mutation transaction; hydrate before opening the commit scope.");
_hasUnflushedMutations = _backingStores.Any(store => store.IsDirty);
await Task.CompletedTask;
await _transactionGate.WaitAsync().ConfigureAwait(false);
try
{
foreach (var store in _backingStores)
store.PullSelected(selector);
_hasUnflushedMutations = _backingStores.Any(store => store.IsDirty);
}
finally
{
_transactionGate.Release();
}
}
/// <summary>
@ -1457,15 +1494,22 @@ namespace GameCult.Caching
/// </summary>
public void FlushAllBackingStores(bool soft = false)
{
foreach (var store in _backingStores)
{
FlushBackingStore(store, soft);
}
if (_ambientTransaction.Value != null)
throw new InvalidOperationException("A CultCache transaction owns durable commit; do not flush inside its stage callback.");
RecomputeDirtyState();
if (!IsDirty)
_transactionGate.Wait();
try
{
LastSuccessfulFlushAtUtc = DateTimeOffset.UtcNow;
foreach (var store in _backingStores)
FlushBackingStoreCore(store, soft);
RecomputeDirtyState();
if (!IsDirty)
LastSuccessfulFlushAtUtc = DateTimeOffset.UtcNow;
}
finally
{
_transactionGate.Release();
}
}
@ -1489,6 +1533,22 @@ namespace GameCult.Caching
throw new InvalidOperationException("Backing store is not attached to this cache.");
}
if (_ambientTransaction.Value != null)
throw new InvalidOperationException("A CultCache transaction owns durable commit; do not flush inside its stage callback.");
_transactionGate.Wait();
try
{
FlushBackingStoreCore(store, soft);
}
finally
{
_transactionGate.Release();
}
}
private void FlushBackingStoreCore(CacheBackingStore store, bool soft)
{
store.PushAll(soft);
RecomputeDirtyState();
if (!store.IsDirty)
@ -1514,17 +1574,79 @@ namespace GameCult.Caching
return Task.CompletedTask;
}
/// <summary>
/// Runs a buffered cache mutation. Staged records are visible only to the executing
/// async flow. The durable backing store is committed before the live cache and its
/// observers advance; an exception discards the entire staged batch.
/// </summary>
public async Task ExecuteTransactionAsync(Func<Task> stageAsync, bool soft = false)
{
if (stageAsync == null) throw new ArgumentNullException(nameof(stageAsync));
if (_ambientTransaction.Value != null)
{
await stageAsync().ConfigureAwait(false);
return;
}
await _transactionGate.WaitAsync().ConfigureAwait(false);
var transaction = new CultCacheTransaction();
IReadOnlyList<(CultStoredDocument Stored, object? Previous, bool Removed)> changes;
_ambientTransaction.Value = transaction;
try
{
await stageAsync().ConfigureAwait(false);
transaction.Seal();
changes = CommitTransaction(transaction, soft);
}
finally
{
transaction.Seal();
_ambientTransaction.Value = null;
_transactionGate.Release();
}
foreach (var change in changes)
PublishChange(change.Stored, change.Previous, change.Removed);
}
/// <summary>Runs a value-producing buffered cache mutation.</summary>
public async Task<T> ExecuteTransactionAsync<T>(Func<Task<T>> stageAsync, bool soft = false)
{
if (stageAsync == null) throw new ArgumentNullException(nameof(stageAsync));
T result = default!;
await ExecuteTransactionAsync(async () =>
{
result = await stageAsync().ConfigureAwait(false);
}, soft).ConfigureAwait(false);
return result;
}
/// <summary>
/// Adds or replaces a typed document and returns its record handle.
/// </summary>
public async Task<CultRecordHandle<T>> AddAsync<T>(T document, CultRecordHandle<T>? handle = null)
{
if (document == null) throw new ArgumentNullException(nameof(document));
var stored = await AddStoredDocumentInternal(
CreateStoredDocument(typeof(T), document, handle?.Key),
source: null,
raiseUpdate: false);
return new CultRecordHandle<T>(stored.Key);
if (_ambientTransaction.Value is { } transaction)
{
var staged = CreateStoredDocument(typeof(T), document, handle?.Key);
transaction.Stage(staged);
return new CultRecordHandle<T>(staged.Key);
}
await _transactionGate.WaitAsync().ConfigureAwait(false);
try
{
var stored = await AddStoredDocumentInternal(
CreateStoredDocument(typeof(T), document, handle?.Key),
source: null,
raiseUpdate: false).ConfigureAwait(false);
return new CultRecordHandle<T>(stored.Key);
}
finally
{
_transactionGate.Release();
}
}
/// <summary>
@ -1549,11 +1671,26 @@ namespace GameCult.Caching
nameof(document));
}
var stored = await AddStoredDocumentInternal(
CreateStoredDocument(documentType, document, key),
source: null,
raiseUpdate: false).ConfigureAwait(false);
return stored.Key;
if (_ambientTransaction.Value is { } transaction)
{
var staged = CreateStoredDocument(documentType, document, key);
transaction.Stage(staged);
return staged.Key;
}
await _transactionGate.WaitAsync().ConfigureAwait(false);
try
{
var stored = await AddStoredDocumentInternal(
CreateStoredDocument(documentType, document, key),
source: null,
raiseUpdate: false).ConfigureAwait(false);
return stored.Key;
}
finally
{
_transactionGate.Release();
}
}
/// <summary>
@ -1572,9 +1709,15 @@ namespace GameCult.Caching
/// </summary>
public object? Get(CultRecordKey key)
{
return _entries.TryGetValue(key.Value, out var stored)
? stored.Document
: null;
lock (_stateGate)
{
if (_ambientTransaction.Value is { } transaction &&
transaction.TryGet(key, out var staged))
return staged?.Document;
return _entries.TryGetValue(key.Value, out var stored)
? stored.Document
: null;
}
}
/// <summary>
@ -1607,17 +1750,13 @@ namespace GameCult.Caching
/// </summary>
public IEnumerable<CultStoredDocument> GetStoredDocuments<T>() where T : class
{
var type = typeof(T);
if (type.IsSealed)
lock (_stateGate)
{
return _typeMaps.TryGetValue(type, out var exact)
? exact.Values
: Enumerable.Empty<CultStoredDocument>();
var type = typeof(T);
return VisibleStoredDocuments()
.Where(entry => type.IsAssignableFrom(entry.Descriptor.DocumentType))
.ToArray();
}
return _typeMaps
.Where(pair => type.IsAssignableFrom(pair.Key))
.SelectMany(pair => pair.Value.Values);
}
/// <summary>
@ -1690,9 +1829,21 @@ namespace GameCult.Caching
/// </summary>
public void Remove<T>(CultRecordHandle<T> handle)
{
if (_entries.TryGetValue(handle.Key.Value, out var stored))
if (_ambientTransaction.Value is { } transaction)
{
RemoveStoredDocumentInternal(stored, source: null, raiseUpdate: false);
transaction.Delete(handle.Key);
return;
}
_transactionGate.Wait();
try
{
if (_entries.TryGetValue(handle.Key.Value, out var stored))
RemoveStoredDocumentInternal(stored, source: null, raiseUpdate: false);
}
finally
{
_transactionGate.Release();
}
}
@ -1701,12 +1852,27 @@ namespace GameCult.Caching
/// </summary>
public bool Remove(CultRecordKey key)
{
if (!_entries.TryGetValue(key.Value, out var stored))
if (Get(key) == null)
{
return false;
}
RemoveStoredDocumentInternal(stored, source: null, raiseUpdate: false);
if (_ambientTransaction.Value is { } transaction)
{
transaction.Delete(key);
return true;
}
_transactionGate.Wait();
try
{
if (_entries.TryGetValue(key.Value, out var stored))
RemoveStoredDocumentInternal(stored, source: null, raiseUpdate: false);
}
finally
{
_transactionGate.Release();
}
return true;
}
@ -1734,9 +1900,171 @@ namespace GameCult.Caching
store.Dispose();
}
_transactionGate.Dispose();
_changes.Dispose();
}
private IEnumerable<CultStoredDocument> VisibleStoredDocuments()
{
if (_ambientTransaction.Value == null)
return _entries.Values.ToArray();
var visible = _entries.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
foreach (var pair in _ambientTransaction.Value.Mutations)
{
if (pair.Value == null)
visible.Remove(pair.Key);
else
visible[pair.Key] = pair.Value;
}
return visible.Values.ToArray();
}
private IReadOnlyList<(CultStoredDocument Stored, object? Previous, bool Removed)> CommitTransaction(
CultCacheTransaction transaction,
bool soft)
{
var mutations = transaction.SnapshotForCommit()
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.ToArray();
if (mutations.Length == 0)
return Array.Empty<(CultStoredDocument, object?, bool)>();
if (_backingStores.Count > 1)
{
throw new InvalidOperationException(
"A CultCache transaction requires zero or one durable backing store; multiple stores cannot share one atomic commit boundary.");
}
CultStoredDocument[] previous;
lock (_stateGate)
{
previous = mutations
.Select(pair => _entries.TryGetValue(pair.Key, out var stored) ? stored : null)
.Where(stored => stored != null)
.Cast<CultStoredDocument>()
.ToArray();
}
var upserts = mutations.Where(pair => pair.Value != null).Select(pair => pair.Value!).ToArray();
var deleted = mutations
.Where(pair => pair.Value == null)
.Select(pair => previous.FirstOrDefault(stored => string.Equals(stored.Key.Value, pair.Key, StringComparison.Ordinal)))
.Where(stored => stored != null)
.Cast<CultStoredDocument>()
.ToArray();
foreach (var store in _backingStores)
store.CommitBatch(upserts, deleted, soft);
var changes = new List<(CultStoredDocument Stored, object? Previous, bool Removed)>();
lock (_stateGate)
{
foreach (var pair in mutations)
{
_entries.TryGetValue(pair.Key, out var existing);
if (existing != null)
{
RemoveIndexes(existing);
_documentHandles.Remove(existing.Document);
_soa.Remove(existing);
}
if (pair.Value == null)
{
if (existing != null)
{
_entries.TryRemove(pair.Key, out _);
changes.Add((existing, existing.Document, true));
}
continue;
}
var stored = pair.Value;
_entries[pair.Key] = stored;
_documentHandles.Remove(stored.Document);
_documentHandles.Add(stored.Document, new DocumentHandleBox(stored.Key));
_soa.Upsert(stored);
AddIndexes(stored);
changes.Add((stored, existing?.Document, false));
}
_hasUnflushedMutations = _backingStores.Any(store => store.IsDirty);
if (!IsDirty)
LastSuccessfulFlushAtUtc = DateTimeOffset.UtcNow;
}
return changes;
}
private sealed class CultCacheTransaction
{
private readonly object _gate = new();
private readonly Dictionary<string, CultStoredDocument?> _mutations = new(StringComparer.Ordinal);
private bool _sealed;
public IReadOnlyDictionary<string, CultStoredDocument?> Mutations
{
get
{
lock (_gate)
{
ThrowIfSealedForAmbientAccess();
return new Dictionary<string, CultStoredDocument?>(_mutations, StringComparer.Ordinal);
}
}
}
public void Stage(CultStoredDocument stored)
{
lock (_gate)
{
ThrowIfSealedForAmbientAccess();
_mutations[stored.Key.Value] = stored;
}
}
public void Delete(CultRecordKey key)
{
lock (_gate)
{
ThrowIfSealedForAmbientAccess();
_mutations[key.Value] = null;
}
}
public bool TryGet(CultRecordKey key, out CultStoredDocument? stored)
{
lock (_gate)
{
ThrowIfSealedForAmbientAccess();
return _mutations.TryGetValue(key.Value, out stored);
}
}
public void Seal()
{
lock (_gate)
_sealed = true;
}
public IReadOnlyDictionary<string, CultStoredDocument?> SnapshotForCommit()
{
lock (_gate)
{
if (!_sealed)
throw new InvalidOperationException("A CultCache transaction must be sealed before commit.");
return new Dictionary<string, CultStoredDocument?>(_mutations, StringComparer.Ordinal);
}
}
private void ThrowIfSealedForAmbientAccess()
{
if (_sealed)
throw new InvalidOperationException(
"This CultCache transaction has already completed; escaped async work cannot read or mutate it.");
}
}
internal CultStoredDocument CreateStoredDocument(Type documentType, object document, CultRecordKey? key = null, string? storedAt = null)
{
var descriptor = _registry.GetRequired(documentType);
@ -1754,33 +2082,28 @@ namespace GameCult.Caching
bool raiseUpdate)
{
CultStoredDocument? existing = null;
_entries.TryGetValue(stored.Key.Value, out existing);
if (existing != null)
lock (_stateGate)
{
RemoveIndexes(existing);
}
_entries.TryGetValue(stored.Key.Value, out existing);
if (existing != null)
RemoveIndexes(existing);
_entries[stored.Key.Value] = stored;
_documentHandles.Remove(stored.Document);
_documentHandles.Add(stored.Document, new DocumentHandleBox(stored.Key));
_soa.Upsert(stored);
AddIndexes(stored);
_entries[stored.Key.Value] = stored;
_documentHandles.Remove(stored.Document);
_documentHandles.Add(stored.Document, new DocumentHandleBox(stored.Key));
_soa.Upsert(stored);
AddIndexes(stored);
foreach (var store in _backingStores)
{
if (store != source)
foreach (var store in _backingStores)
{
store.Push(stored);
if (store != source)
store.Push(stored);
}
}
if (source == null)
{
_hasUnflushedMutations = true;
}
else
{
RecomputeDirtyState();
if (source == null)
_hasUnflushedMutations = true;
else
RecomputeDirtyState();
}
PublishChange(stored, existing?.Document);
@ -1799,30 +2122,26 @@ namespace GameCult.Caching
CacheBackingStore? source,
bool raiseUpdate)
{
if (!_entries.TryRemove(stored.Key.Value, out var existing))
CultStoredDocument? existing;
lock (_stateGate)
{
return;
}
if (!_entries.TryRemove(stored.Key.Value, out existing))
return;
RemoveIndexes(existing);
_documentHandles.Remove(existing.Document);
_soa.Remove(existing);
RemoveIndexes(existing);
_documentHandles.Remove(existing.Document);
_soa.Remove(existing);
foreach (var store in _backingStores)
{
if (store != source)
foreach (var store in _backingStores)
{
store.Delete(existing);
if (store != source)
store.Delete(existing);
}
}
if (source == null)
{
_hasUnflushedMutations = true;
}
else
{
RecomputeDirtyState();
if (source == null)
_hasUnflushedMutations = true;
else
RecomputeDirtyState();
}
PublishChange(existing, existing.Document, removed: true);
@ -2104,6 +2423,34 @@ namespace GameCult.Caching
/// </summary>
public abstract void Delete(CultStoredDocument entry);
/// <summary>
/// Atomically stages and durably commits a buffered record batch for this store.
/// Implementations must restore their prior staged view when finality fails.
/// </summary>
public virtual void CommitBatch(
IReadOnlyCollection<CultStoredDocument> upserts,
IReadOnlyCollection<CultStoredDocument> deletes,
bool soft)
{
var previousEntries = Entries.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
var wasDirty = IsDirty;
try
{
foreach (var entry in deletes)
Delete(entry);
foreach (var entry in upserts)
Push(entry);
PushAll(soft);
}
catch
{
Entries.Clear();
foreach (var pair in previousEntries)
Entries[pair.Key] = pair.Value;
IsDirty = wasDirty;
throw;
}
}
/// <summary>
/// Persists all current backing store entries.
/// </summary>
public abstract void PushAll(bool soft = false);

View file

@ -42,11 +42,11 @@ namespace GameCult.Mesh.Quic.Native
public async Task<ICultMeshRealtimeTransport> ConnectAsync(
CultMeshTransportCandidate candidate,
CultMeshEndpointId endpointId,
CultMeshSessionTarget target,
CancellationToken cancellationToken = default)
{
if (candidate == null) throw new ArgumentNullException(nameof(candidate));
if (endpointId == null) throw new ArgumentNullException(nameof(endpointId));
if (target == null) throw new ArgumentNullException(nameof(target));
if (!TryParseEndpoint(candidate.Endpoint, out var host, out var port, out var pin))
throw new NotSupportedException($"Native QUIC connector does not support '{candidate.Endpoint}'.");
@ -72,7 +72,9 @@ namespace GameCult.Mesh.Quic.Native
var transport = new CultMeshNativeQuicRealtimeTransport(
candidate.Endpoint,
handle,
_options.PollInterval);
_options.PollInterval,
target,
candidate.Generation);
try
{
await transport.WaitUntilConnectedAsync(_options.HandshakeTimeout, cancellationToken).ConfigureAwait(false);
@ -122,23 +124,42 @@ namespace GameCult.Mesh.Quic.Native
}
}
internal sealed class CultMeshNativeQuicRealtimeTransport : ICultMeshRealtimeTransport
internal sealed class CultMeshNativeQuicRealtimeTransport : ICultMeshRealtimeTransport, ICultMeshVerifiedTransport
{
private readonly object _gate = new object();
private readonly TimeSpan _pollInterval;
private IntPtr _handle;
private readonly CultMeshSessionTarget _target;
private readonly string _routeGeneration;
private bool _disposed;
public CultMeshNativeQuicRealtimeTransport(string endpoint, IntPtr handle, TimeSpan pollInterval)
public CultMeshNativeQuicRealtimeTransport(
string endpoint,
IntPtr handle,
TimeSpan pollInterval,
CultMeshSessionTarget target,
string routeGeneration)
{
Endpoint = endpoint;
_handle = handle;
_pollInterval = pollInterval;
_target = target;
_routeGeneration = routeGeneration ?? string.Empty;
}
public string TransportId => "msquic-native-realtime";
public string Endpoint { get; }
public bool IsVerifiedFor(
string verseId,
string authorityRuntimeId,
string protocolId,
string routeGeneration) =>
string.Equals(_target.VerseId, verseId, StringComparison.Ordinal) &&
string.Equals(_target.AuthorityRuntimeId, authorityRuntimeId, StringComparison.Ordinal) &&
string.Equals(CultMeshProtocols.RealtimeState.Value, protocolId, StringComparison.Ordinal) &&
string.Equals(_routeGeneration, routeGeneration, StringComparison.Ordinal);
public Task SendAsync(CultMeshRealtimeFrame frame, CancellationToken cancellationToken = default) =>
Task.FromException(new NotSupportedException(
"The Unity-compatible native QUIC connector currently owns provider-to-client state only."));

View file

@ -22,7 +22,7 @@ public sealed class CultMeshQuicRealtimeConnectorOptions
/// <summary>
/// Optional provider-aware certificate validator. Standard TLS validation is used when omitted.
/// </summary>
public Func<CultMeshEndpointId, X509Certificate2?, X509Chain?, SslPolicyErrors, bool>?
public Func<CultMeshRuntimeId, X509Certificate2?, X509Chain?, SslPolicyErrors, bool>?
ValidateProviderCertificate { get; set; }
}
@ -55,11 +55,11 @@ public sealed class CultMeshQuicRealtimeTransportConnector : ICultMeshRealtimeTr
/// <inheritdoc />
public async Task<ICultMeshRealtimeTransport> ConnectAsync(
CultMeshTransportCandidate candidate,
CultMeshEndpointId endpointId,
CultMeshSessionTarget target,
CancellationToken cancellationToken = default)
{
if (candidate == null) throw new ArgumentNullException(nameof(candidate));
if (endpointId == null) throw new ArgumentNullException(nameof(endpointId));
if (target == null) throw new ArgumentNullException(nameof(target));
if (!QuicConnection.IsSupported)
throw new PlatformNotSupportedException("QUIC is unavailable. Install a supported MsQuic runtime.");
if (!TryParseEndpoint(candidate.Endpoint, out var host, out var port))
@ -84,14 +84,15 @@ public sealed class CultMeshQuicRealtimeTransportConnector : ICultMeshRealtimeTr
{
var providerCertificate = certificate as X509Certificate2 ??
(certificate == null ? null : new X509Certificate2(certificate));
return validator?.Invoke(endpointId, providerCertificate, chain, errors) ??
return validator?.Invoke(CultMeshRuntimeId.Parse(target.AuthorityRuntimeId), providerCertificate, chain, errors) ??
errors == SslPolicyErrors.None ||
MatchesAdvertisedCertificatePin(candidate.Endpoint, providerCertificate);
}
}
}, cancellationToken).ConfigureAwait(false);
return new CultMeshQuicRealtimeTransport(candidate.Endpoint, connection);
return new CultMeshQuicRealtimeTransport(
candidate.Endpoint, connection, target: target, routeGeneration: candidate.Generation);
}
private static bool TryParseEndpoint(string? endpoint, out string host, out int port)
@ -308,7 +309,7 @@ public sealed class CultMeshQuicRealtimeServer : IAsyncDisposable
}
}
internal sealed class CultMeshQuicRealtimeTransport : ICultMeshRealtimeTransport
internal sealed class CultMeshQuicRealtimeTransport : ICultMeshRealtimeTransport, ICultMeshVerifiedTransport
{
private readonly QuicConnection _connection;
private readonly CancellationTokenSource _shutdown = new();
@ -318,6 +319,8 @@ internal sealed class CultMeshQuicRealtimeTransport : ICultMeshRealtimeTransport
private readonly SemaphoreSlim _reliableSendGate = new(1, 1);
private readonly CultMeshRealtimeInbox _outbound = new();
private readonly Action<CultMeshRealtimeFrame>? _observer;
private readonly CultMeshSessionTarget? _target;
private readonly string _routeGeneration;
private readonly Task _acceptLoop;
private readonly Task _outboundLoop;
private readonly Task _completion;
@ -328,11 +331,15 @@ internal sealed class CultMeshQuicRealtimeTransport : ICultMeshRealtimeTransport
public CultMeshQuicRealtimeTransport(
string endpoint,
QuicConnection connection,
Action<CultMeshRealtimeFrame>? observer = null)
Action<CultMeshRealtimeFrame>? observer = null,
CultMeshSessionTarget? target = null,
string routeGeneration = "")
{
Endpoint = endpoint;
_connection = connection;
_observer = observer;
_target = target;
_routeGeneration = routeGeneration ?? string.Empty;
_acceptLoop = AcceptStreamsAsync(_shutdown.Token);
_outboundLoop = SendPublishedFramesAsync(_shutdown.Token);
_completion = Task.WhenAll(_acceptLoop, _outboundLoop);
@ -342,6 +349,17 @@ internal sealed class CultMeshQuicRealtimeTransport : ICultMeshRealtimeTransport
public string Endpoint { get; }
public Task Completion => _completion;
public bool IsVerifiedFor(
string verseId,
string authorityRuntimeId,
string protocolId,
string routeGeneration) =>
_target != null &&
string.Equals(_target.VerseId, verseId, StringComparison.Ordinal) &&
string.Equals(_target.AuthorityRuntimeId, authorityRuntimeId, StringComparison.Ordinal) &&
string.Equals(CultMeshProtocols.RealtimeState.Value, protocolId, StringComparison.Ordinal) &&
string.Equals(_routeGeneration, routeGeneration, StringComparison.Ordinal);
internal bool TryPublishLatest(CultMeshRealtimeFrame frame)
{
if (_disposed || frame.Delivery != CultMeshRealtimeDelivery.LatestOnly) return false;

View file

@ -0,0 +1,349 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using GameCult.Networking;
namespace GameCult.Mesh
{
public enum CultMeshAuthorityTrustMode
{
AuthenticatedRemote,
LocalDevelopment
}
public sealed class CultMeshEcdsaP256PublicKey
{
public CultMeshEcdsaP256PublicKey(string keyId, string x, string y)
{
KeyId = Require(keyId, nameof(keyId));
X = RequireCoordinate(x, nameof(x));
Y = RequireCoordinate(y, nameof(y));
}
public string KeyId { get; }
public string X { get; }
public string Y { get; }
internal ECDsa CreateVerifier()
{
var verifier = ECDsa.Create();
verifier.ImportParameters(new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
Q = new ECPoint { X = Convert.FromBase64String(X), Y = Convert.FromBase64String(Y) }
});
return verifier;
}
public static CultMeshEcdsaP256PublicKey From(string keyId, ECDsa key)
{
if (key == null) throw new ArgumentNullException(nameof(key));
var parameters = key.ExportParameters(false);
return new CultMeshEcdsaP256PublicKey(
keyId,
Convert.ToBase64String(parameters.Q.X ?? throw new InvalidOperationException("P-256 key has no X coordinate.")),
Convert.ToBase64String(parameters.Q.Y ?? throw new InvalidOperationException("P-256 key has no Y coordinate.")));
}
private static string RequireCoordinate(string value, string name)
{
var required = Require(value, name);
byte[] bytes;
try { bytes = Convert.FromBase64String(required); }
catch (FormatException error) { throw new ArgumentException("P-256 coordinates must be base64.", name, error); }
if (bytes.Length != 32) throw new ArgumentException("P-256 coordinates must contain 32 bytes.", name);
return required;
}
private static string Require(string value, string name) =>
string.IsNullOrWhiteSpace(value) ? throw new ArgumentException("Value must be non-empty.", name) : value.Trim();
}
public sealed class CultMeshRouteCertificate
{
public CultMeshRouteCertificate(
CultMeshEcdsaP256PublicKey providerKey,
string odinKeyId,
long issuedAtUnixMilliseconds,
long expiresAtUnixMilliseconds,
string signature)
{
ProviderKey = providerKey ?? throw new ArgumentNullException(nameof(providerKey));
OdinKeyId = string.IsNullOrWhiteSpace(odinKeyId)
? throw new ArgumentException("Odin key identity is required.", nameof(odinKeyId))
: odinKeyId.Trim();
if (issuedAtUnixMilliseconds < 0) throw new ArgumentOutOfRangeException(nameof(issuedAtUnixMilliseconds));
if (expiresAtUnixMilliseconds <= issuedAtUnixMilliseconds) throw new ArgumentOutOfRangeException(nameof(expiresAtUnixMilliseconds));
IssuedAtUnixMilliseconds = issuedAtUnixMilliseconds;
ExpiresAtUnixMilliseconds = expiresAtUnixMilliseconds;
Signature = signature?.Trim() ?? string.Empty;
}
public CultMeshEcdsaP256PublicKey ProviderKey { get; }
public string OdinKeyId { get; }
public long IssuedAtUnixMilliseconds { get; }
public long ExpiresAtUnixMilliseconds { get; }
public string Signature { get; }
}
public sealed class CultMeshAuthorityTrustPolicy
{
private readonly Dictionary<string, CultMeshEcdsaP256PublicKey> _odinRoots;
public CultMeshAuthorityTrustPolicy(
CultMeshAuthorityTrustMode mode,
IEnumerable<CultMeshEcdsaP256PublicKey>? odinRoots = null)
{
Mode = mode;
_odinRoots = (odinRoots ?? Array.Empty<CultMeshEcdsaP256PublicKey>())
.ToDictionary(key => key.KeyId, StringComparer.Ordinal);
}
public CultMeshAuthorityTrustMode Mode { get; }
public IReadOnlyCollection<CultMeshEcdsaP256PublicKey> OdinRoots => _odinRoots.Values;
/// <summary>Fails closed unless the route is valid under this consumer-owned policy.</summary>
public void Validate(string verseId, CultMeshAuthorityRoute route, DateTimeOffset now) =>
Verify(verseId, route, now);
internal CultMeshVerifiedAuthorityRoute Verify(
string verseId,
CultMeshAuthorityRoute route,
DateTimeOffset now)
{
if (route == null) throw new ArgumentNullException(nameof(route));
var certificate = route.Certificate;
if (certificate == null || string.IsNullOrWhiteSpace(certificate.Signature))
{
if (Mode == CultMeshAuthorityTrustMode.LocalDevelopment && IsLoopback(route.Endpoint))
return CultMeshVerifiedAuthorityRoute.Local(route);
throw Failure(route, "Remote CultMesh routes require an Odin-signed authority certificate.");
}
if (!IsProtected(route.Endpoint) &&
!(Mode == CultMeshAuthorityTrustMode.LocalDevelopment && IsLoopback(route.Endpoint)))
throw Failure(route, "Authenticated remote CultMesh routes require TLS or QUIC channel protection.");
if (!_odinRoots.TryGetValue(certificate.OdinKeyId, out var root))
throw Failure(route, $"Odin key '{certificate.OdinKeyId}' is not trusted by this consumer.");
var nowMs = now.ToUnixTimeMilliseconds();
if (nowMs < certificate.IssuedAtUnixMilliseconds || nowMs >= certificate.ExpiresAtUnixMilliseconds)
throw Failure(route, "The Odin route certificate is not currently valid.");
byte[] signature;
try { signature = Convert.FromBase64String(certificate.Signature); }
catch (FormatException error) { throw Failure(route, "The Odin route signature is not base64.", error); }
if (signature.Length != 64) throw Failure(route, "The Odin route signature is not IEEE P1363 P-256.");
using var verifier = root.CreateVerifier();
if (!verifier.VerifyData(
CultMeshAuthorityProof.CanonicalRoute(verseId, route),
signature,
HashAlgorithmName.SHA256))
throw Failure(route, "The Odin route certificate signature is invalid.");
return CultMeshVerifiedAuthorityRoute.Authenticated(route, certificate.ProviderKey);
}
private static bool IsLoopback(string endpoint)
{
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) return false;
return uri.IsLoopback ||
string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase) ||
string.Equals(uri.Host, "127.0.0.1", StringComparison.Ordinal) ||
string.Equals(uri.Host, "::1", StringComparison.Ordinal);
}
private static bool IsProtected(string endpoint)
{
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) return false;
return string.Equals(uri.Scheme, "wss", StringComparison.OrdinalIgnoreCase) ||
string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase) ||
uri.Scheme.IndexOf("quic", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static CultMeshSessionException Failure(CultMeshAuthorityRoute route, string message, Exception? inner = null) =>
new CultMeshSessionException(new CultMeshSessionFailure(
CultMeshSessionFailureReason.Authentication,
message,
route.Endpoint), inner);
}
internal sealed class CultMeshVerifiedAuthorityRoute
{
private CultMeshVerifiedAuthorityRoute(
CultMeshAuthorityRoute route,
CultMeshEcdsaP256PublicKey? providerKey,
bool localDevelopment)
{
Route = route;
ProviderKey = providerKey;
IsLocalDevelopment = localDevelopment;
}
public CultMeshAuthorityRoute Route { get; }
public CultMeshEcdsaP256PublicKey? ProviderKey { get; }
public bool IsLocalDevelopment { get; }
public static CultMeshVerifiedAuthorityRoute Authenticated(CultMeshAuthorityRoute route, CultMeshEcdsaP256PublicKey key) => new(route, key, false);
public static CultMeshVerifiedAuthorityRoute Local(CultMeshAuthorityRoute route) => new(route, null, true);
}
public sealed class CultMeshSessionProofSigner
{
private readonly ECDsa _key;
public CultMeshSessionProofSigner(CultMeshAuthorityRoute route, ECDsa providerPrivateKey)
{
Route = route ?? throw new ArgumentNullException(nameof(route));
_key = providerPrivateKey ?? throw new ArgumentNullException(nameof(providerPrivateKey));
var certificate = route.Certificate ?? throw new ArgumentException("A session proof signer requires a certified route.", nameof(route));
ProviderKeyId = certificate.ProviderKey.KeyId;
var exported = CultMeshEcdsaP256PublicKey.From(ProviderKeyId, _key);
if (!string.Equals(exported.X, certificate.ProviderKey.X, StringComparison.Ordinal) ||
!string.Equals(exported.Y, certificate.ProviderKey.Y, StringComparison.Ordinal))
throw new ArgumentException("Provider private key does not match the certified route key.", nameof(providerPrivateKey));
}
public CultMeshAuthorityRoute Route { get; }
public string ProviderKeyId { get; }
public string Sign(CultMeshSessionOpenMessage request)
{
var bytes = CultMeshAuthorityProof.CanonicalSession(request, Route.Endpoint);
return Convert.ToBase64String(CultMeshAuthorityProof.SignP256(_key, bytes));
}
}
public static class CultMeshAuthorityProof
{
private const string RouteDomain = "gamecult.cultmesh.route-certificate.v1";
private const string SessionDomain = "gamecult.cultmesh.session-proof.v1";
public static CultMeshAuthorityRoute CreateSignedRoute(
string verseId,
string authorityRuntimeId,
string endpoint,
IEnumerable<string> protocolIds,
int priority,
string generation,
CultMeshEcdsaP256PublicKey providerKey,
string odinKeyId,
DateTimeOffset issuedAt,
DateTimeOffset expiresAt,
ECDsa odinPrivateKey)
{
if (odinPrivateKey == null) throw new ArgumentNullException(nameof(odinPrivateKey));
var unsignedCertificate = new CultMeshRouteCertificate(
providerKey,
odinKeyId,
issuedAt.ToUnixTimeMilliseconds(),
expiresAt.ToUnixTimeMilliseconds(),
string.Empty);
var unsignedRoute = new CultMeshAuthorityRoute(
authorityRuntimeId, endpoint, protocolIds, priority, generation, unsignedCertificate);
var signature = Convert.ToBase64String(SignP256(
odinPrivateKey,
CanonicalRoute(verseId, unsignedRoute)));
return new CultMeshAuthorityRoute(
authorityRuntimeId,
endpoint,
protocolIds,
priority,
generation,
new CultMeshRouteCertificate(
providerKey,
odinKeyId,
issuedAt.ToUnixTimeMilliseconds(),
expiresAt.ToUnixTimeMilliseconds(),
signature));
}
/// <summary>Verifies both the Odin route certificate and provider nonce proof.</summary>
public static bool VerifySessionProof(
CultMeshSessionOpenMessage request,
CultMeshSessionAcceptedMessage response,
string verseId,
CultMeshAuthorityRoute route,
CultMeshAuthorityTrustPolicy trust,
DateTimeOffset now)
{
if (request == null) throw new ArgumentNullException(nameof(request));
if (response == null) throw new ArgumentNullException(nameof(response));
if (trust == null) throw new ArgumentNullException(nameof(trust));
return VerifySession(request, response, trust.Verify(verseId, route, now));
}
internal static byte[] CanonicalRoute(string verseId, CultMeshAuthorityRoute route)
{
var certificate = route.Certificate ?? throw new InvalidOperationException("Route certificate is required.");
return Canonical(
RouteDomain,
verseId,
route.AuthorityRuntimeId,
route.Endpoint,
string.Join("\u001f", route.ProtocolIds.OrderBy(value => value, StringComparer.Ordinal)),
route.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture),
route.Generation,
certificate.ProviderKey.KeyId,
certificate.ProviderKey.X,
certificate.ProviderKey.Y,
certificate.OdinKeyId,
certificate.IssuedAtUnixMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture),
certificate.ExpiresAtUnixMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture));
}
internal static byte[] CanonicalSession(CultMeshSessionOpenMessage request, string endpoint) => Canonical(
SessionDomain,
request.ClientNonce ?? string.Empty,
request.MessageId ?? string.Empty,
request.SourceRuntimeId ?? string.Empty,
request.VerseId ?? string.Empty,
request.AuthorityRuntimeId ?? string.Empty,
request.ProtocolId ?? string.Empty,
endpoint ?? string.Empty,
request.RouteGeneration ?? string.Empty);
internal static bool VerifySession(
CultMeshSessionOpenMessage request,
CultMeshSessionAcceptedMessage response,
CultMeshVerifiedAuthorityRoute route)
{
if (route.IsLocalDevelopment) return true;
if (route.ProviderKey == null ||
!string.Equals(response.ProviderKeyId, route.ProviderKey.KeyId, StringComparison.Ordinal) ||
!string.Equals(response.ClientNonce, request.ClientNonce, StringComparison.Ordinal) ||
string.IsNullOrWhiteSpace(response.ProviderSignature))
return false;
byte[] signature;
try { signature = Convert.FromBase64String(response.ProviderSignature); }
catch (FormatException) { return false; }
if (signature.Length != 64) return false;
using var verifier = route.ProviderKey.CreateVerifier();
return verifier.VerifyData(
CanonicalSession(request, route.Route.Endpoint),
signature,
HashAlgorithmName.SHA256);
}
internal static byte[] SignP256(ECDsa key, byte[] payload)
{
var signature = key.SignData(payload, HashAlgorithmName.SHA256);
if (signature.Length != 64)
throw new CryptographicException("ECDSA provider did not emit IEEE P1363 P-256 signature bytes.");
return signature;
}
private static byte[] Canonical(params string[] values)
{
using var stream = new MemoryStream();
var length = new byte[4];
foreach (var value in values)
{
var bytes = Encoding.UTF8.GetBytes(value ?? string.Empty);
BinaryPrimitives.WriteInt32BigEndian(length, bytes.Length);
stream.Write(length, 0, length.Length);
stream.Write(bytes, 0, bytes.Length);
}
return stream.ToArray();
}
}
}

View file

@ -225,9 +225,11 @@ namespace GameCult.Mesh
{
internal CultMeshBodyDemandPlan(
string bodyId,
long generation,
IReadOnlyList<CultMeshBodyConsumerRoute> consumers)
{
BodyId = bodyId;
Generation = generation;
Consumers = consumers;
RequiresSharedMemory = consumers.Any(value => value.Transport == CultMeshBodyTransportKind.SharedMemory);
RequiresSharedFileMapping = consumers.Any(value => value.Transport == CultMeshBodyTransportKind.SharedFileMapping);
@ -235,6 +237,11 @@ namespace GameCult.Mesh
}
public string BodyId { get; }
/// <summary>
/// Monotone generation of the exact routed consumer set. Publishers must rotate any
/// retained capability before writing after this value changes.
/// </summary>
public long Generation { get; }
public bool HasConsumers => Consumers.Count > 0;
public bool RequiresSharedMemory { get; }
public bool RequiresSharedFileMapping { get; }
@ -271,6 +278,8 @@ namespace GameCult.Mesh
private readonly object _gate = new();
private readonly Dictionary<string, CultNetDatabaseSubscriptionDemand> _demands =
new(StringComparer.Ordinal);
private readonly Dictionary<string, long> _bodyGenerations =
new(StringComparer.Ordinal);
private bool _disposed;
public CultMeshBodyDemandTracker(CultNetDatabaseSubscriptionServer subscriptions)
@ -298,7 +307,10 @@ namespace GameCult.Mesh
.OrderBy(value => value.ConsumerRuntimeId, StringComparer.Ordinal)
.ThenBy(value => value.SubscriptionId, StringComparer.Ordinal)
.ToArray();
return new CultMeshBodyDemandPlan(bodyId, routes);
return new CultMeshBodyDemandPlan(
bodyId,
_bodyGenerations.TryGetValue(bodyId, out var generation) ? generation : 0,
routes);
}
}
@ -310,6 +322,7 @@ namespace GameCult.Mesh
_disposed = true;
if (_subscriptions != null) _subscriptions.DemandChanged -= OnDemandChanged;
_demands.Clear();
_bodyGenerations.Clear();
}
}
@ -322,11 +335,63 @@ namespace GameCult.Mesh
lock (_gate)
{
if (_disposed) return;
_demands.TryGetValue(key, out var previous);
var affectedBodyIds = (previous?.BodyIds ?? Array.Empty<string>())
.Concat(demand.BodyIds)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray();
var before = affectedBodyIds.ToDictionary(
bodyId => bodyId,
RoutedConsumerSignature,
StringComparer.Ordinal);
if (demand.Active) _demands[key] = demand;
else _demands.Remove(key);
foreach (var bodyId in affectedBodyIds)
{
var after = RoutedConsumerSignature(bodyId);
if (string.Equals(before[bodyId], after, StringComparison.Ordinal)) continue;
_bodyGenerations[bodyId] = checked(
(_bodyGenerations.TryGetValue(bodyId, out var generation) ? generation : 0) + 1);
}
}
}
/// <summary>
/// Executes one synchronous capability commit only while the exact routed consumer
/// generation remains current. Demand transitions serialize against the callback, so
/// either this work linearizes before the transition or it is rejected without running.
/// </summary>
public bool TryExecuteAtGeneration(string bodyId, long generation, Action action)
{
if (string.IsNullOrWhiteSpace(bodyId)) throw new ArgumentException("Body identity is required.", nameof(bodyId));
if (generation < 0) throw new ArgumentOutOfRangeException(nameof(generation));
if (action == null) throw new ArgumentNullException(nameof(action));
lock (_gate)
{
ThrowIfDisposed();
var current = _bodyGenerations.TryGetValue(bodyId, out var value) ? value : 0;
if (current != generation) return false;
action();
return true;
}
}
private string RoutedConsumerSignature(string bodyId) => string.Join(
"\u001e",
_demands.Values
.Where(value => value.BodyIds.Contains(bodyId, StringComparer.Ordinal))
.Select(Route)
.Where(value => value != null)
.Cast<CultMeshBodyConsumerRoute>()
.OrderBy(value => value.ConsumerRuntimeId, StringComparer.Ordinal)
.ThenBy(value => value.SubscriptionId, StringComparer.Ordinal)
.Select(value => string.Join(
"\u001f",
value.ConsumerRuntimeId,
value.SubscriptionId,
value.Transport.ToString())));
private void OnDemandChanged(CultNetDatabaseSubscriptionDemand demand) => Observe(demand);
private static CultMeshBodyConsumerRoute? Route(CultNetDatabaseSubscriptionDemand demand)

View file

@ -27,8 +27,8 @@ namespace GameCult.Mesh
public IReadOnlyList<ICultMeshTransportConnector>? Connectors { get; set; }
/// <summary>
/// Gets or sets streaming content connectors. TCP content delivery is used by default;
/// applications may replace it or explicitly add legacy RUDP.
/// Gets or sets streaming content connectors. HTTPS and loopback TCP content
/// delivery are used by default; applications may replace them or explicitly add legacy RUDP.
/// </summary>
public IReadOnlyList<ICultMeshContentTransportConnector>? ContentConnectors { get; set; }
@ -184,6 +184,7 @@ namespace GameCult.Mesh
},
options.ContentConnectors ?? new ICultMeshContentTransportConnector[]
{
new CultMeshHttpsContentTransportConnector(),
new CultMeshTcpContentTransportConnector()
},
options.RealtimeConnectors,
@ -391,7 +392,8 @@ namespace GameCult.Mesh
PayloadSchema = requestSchema.Trim(),
PayloadEncoding = "messagepack-base64",
Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(request, CultNetSchemaMessageSerialization.Options)),
SourceRuntimeId = sourceRuntimeId.Trim()
SourceRuntimeId = sourceRuntimeId.Trim(),
TargetRuntimeId = target.AuthorityRuntimeId
};
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(_operationResponseTimeout);
@ -441,6 +443,11 @@ namespace GameCult.Mesh
if (!string.Equals(envelope.ServiceId, expectedService, StringComparison.Ordinal) ||
!string.Equals(envelope.Operation, expectedOperation, StringComparison.Ordinal))
throw new InvalidOperationException("CultMesh operation response did not match the requested service and operation.");
if (!string.Equals(envelope.SourceRuntimeId, target.AuthorityRuntimeId, StringComparison.Ordinal))
throw new CultMeshSessionException(new CultMeshSessionFailure(
CultMeshSessionFailureReason.Authority,
$"CultMesh operation response came from runtime '{envelope.SourceRuntimeId ?? "(missing)"}', " +
$"not selected authority '{target.AuthorityRuntimeId}'."));
if (string.Equals(envelope.PayloadSchema, CultNetOperationServer.FailureSchemaId, StringComparison.Ordinal))
{
if (!string.Equals(envelope.PayloadEncoding, "messagepack-base64", StringComparison.Ordinal))
@ -580,7 +587,7 @@ namespace GameCult.Mesh
using var snapshot = await CultMeshSnapshotSession.ConnectAsync(
_sessions,
target,
CultMeshProtocols.Content,
CultMeshProtocols.Documents,
new CultMeshSnapshotRequestOptions
{
ConnectTimeout = TimeSpan.FromSeconds(5),
@ -795,7 +802,7 @@ namespace GameCult.Mesh
_cache,
new CultRecordKey(recordKey),
CultMesh.Verse(target.VerseId, "cultmesh-client").Context,
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, target.ProviderRuntimeId));
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, target.AuthorityRuntimeId));
}
public CultMeshDocumentHandle<TDocument> Handle { get; }
@ -897,7 +904,7 @@ namespace GameCult.Mesh
_subscription = new CultNetDatabaseSubscriptionClient(session.OpenSchemaClient(), _cache, networkRegistry);
Handle = CultMesh.Collection<TDocument>(
_cache,
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, target.ProviderRuntimeId));
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, target.AuthorityRuntimeId));
}
public CultMeshCollectionHandle<TDocument> Handle { get; }

View file

@ -122,31 +122,58 @@ namespace GameCult.Mesh
public async Task<ICultMeshContentTransport> ConnectAsync(
CultMeshTransportCandidate candidate,
CultMeshEndpointId endpointId,
CultMeshSessionTarget target,
CancellationToken cancellationToken = default)
{
if (!CanConnect(candidate))
throw new NotSupportedException($"Legacy RUDP content connector does not support '{candidate.Endpoint}'.");
var client = await _schemaConnector.ConnectAsync(candidate, CultMeshProtocols.Content, cancellationToken)
.ConfigureAwait(false);
return new LegacyRudpContentTransport(candidate.Endpoint, client, _options);
try
{
await CultMeshSessionIdentityClient.VerifyAsync(
client,
"cultmesh-content-client",
target,
CultMeshProtocols.Content,
candidate,
null,
_options.Clock.UtcNow,
_options.ResponseTimeout,
cancellationToken).ConfigureAwait(false);
return new LegacyRudpContentTransport(
candidate.Endpoint, client, _options, target, candidate.Generation);
}
catch
{
client.Dispose();
throw;
}
}
private sealed class LegacyRudpContentTransport : ICultMeshContentTransport
private sealed class LegacyRudpContentTransport : ICultMeshContentTransport, ICultMeshVerifiedTransport
{
private readonly ICultNetSchemaClient _client;
private readonly CultMeshLegacyRudpContentTransportOptions _options;
private readonly string _verseId;
private readonly string _authorityRuntimeId;
private readonly string _routeGeneration;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, TaskCompletionSource<CultMeshContentChunkResponseMessage>> _pending = new(StringComparer.Ordinal);
private bool _disposed;
public LegacyRudpContentTransport(
string endpoint,
ICultNetSchemaClient client,
CultMeshLegacyRudpContentTransportOptions options)
CultMeshLegacyRudpContentTransportOptions options,
CultMeshSessionTarget target,
string routeGeneration)
{
Endpoint = endpoint;
_client = client;
_options = options;
_verseId = target.VerseId;
_authorityRuntimeId = target.AuthorityRuntimeId;
_routeGeneration = routeGeneration ?? string.Empty;
_client.OnCultNet<CultMeshContentChunkResponseMessage>(OnResponse);
_client.OnCultNet<CultNetErrorMessage>(OnError);
}
@ -154,6 +181,16 @@ namespace GameCult.Mesh
public string TransportId => "legacy-rudp-content";
public string Endpoint { get; }
public bool IsVerifiedFor(
string verseId,
string authorityRuntimeId,
string protocolId,
string routeGeneration) =>
string.Equals(_verseId, verseId, StringComparison.Ordinal) &&
string.Equals(_authorityRuntimeId, authorityRuntimeId, StringComparison.Ordinal) &&
string.Equals(CultMeshProtocols.Content.Value, protocolId, StringComparison.Ordinal) &&
string.Equals(_routeGeneration, routeGeneration, StringComparison.Ordinal);
public async Task CopyChunkToAsync(
CultMeshCdnChunkRef chunk,
Stream destination,

View file

@ -36,7 +36,7 @@ namespace GameCult.Mesh
bool CanConnect(CultMeshTransportCandidate candidate);
Task<ICultMeshContentTransport> ConnectAsync(
CultMeshTransportCandidate candidate,
CultMeshEndpointId endpointId,
CultMeshSessionTarget target,
CancellationToken cancellationToken = default);
}

View file

@ -267,7 +267,8 @@ namespace GameCult.Mesh
var observations = results.SelectMany(result => result.Observations)
.Where(observation => query.VerseIds.Count == 0 || query.VerseIds.Contains(observation.Candidate.VerseId, StringComparer.Ordinal))
.Where(observation => query.AuthorityRuntimeId == null ||
observation.Candidate.AuthorityRuntimeIds.Contains(query.AuthorityRuntimeId, StringComparer.Ordinal))
observation.Candidate.AuthorityRoutes.Any(route =>
string.Equals(route.AuthorityRuntimeId, query.AuthorityRuntimeId, StringComparison.Ordinal)))
.ToArray();
foreach (var rejected in observations.Where(observation => observation.Trust == CultMeshDiscoveryTrust.Rejected))

View file

@ -0,0 +1,124 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace GameCult.Mesh
{
/// <summary>
/// Opens immutable content over an Odin-certified HTTPS route. Provider
/// identity comes from the verified route and TLS endpoint; the transfer
/// service remains responsible for checking the advertised chunk hash.
/// </summary>
public sealed class CultMeshHttpsContentTransportConnector : ICultMeshContentTransportConnector
{
private readonly Func<HttpMessageHandler> _createHandler;
public CultMeshHttpsContentTransportConnector(Func<HttpMessageHandler>? createHandler = null)
{
_createHandler = createHandler ?? (() => new HttpClientHandler());
}
public string ConnectorId => "https-content";
public int Priority => 0;
public bool CanConnect(CultMeshTransportCandidate candidate) =>
candidate != null &&
Uri.TryCreate(candidate.Endpoint, UriKind.Absolute, out var endpoint) &&
string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
public Task<ICultMeshContentTransport> ConnectAsync(
CultMeshTransportCandidate candidate,
CultMeshSessionTarget target,
CancellationToken cancellationToken = default)
{
if (candidate == null) throw new ArgumentNullException(nameof(candidate));
if (target == null) throw new ArgumentNullException(nameof(target));
if (!CanConnect(candidate))
throw new NotSupportedException($"HTTPS content connector does not support '{candidate.Endpoint}'.");
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult<ICultMeshContentTransport>(new CultMeshHttpsContentTransport(
candidate.Endpoint,
new HttpClient(_createHandler(), disposeHandler: true),
target,
candidate.Generation));
}
}
internal sealed class CultMeshHttpsContentTransport : ICultMeshContentTransport, ICultMeshVerifiedTransport
{
private readonly HttpClient _client;
private readonly CultMeshSessionTarget _target;
private readonly string _routeGeneration;
private bool _disposed;
public CultMeshHttpsContentTransport(
string endpoint,
HttpClient client,
CultMeshSessionTarget target,
string routeGeneration)
{
Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint));
_client = client ?? throw new ArgumentNullException(nameof(client));
_target = target ?? throw new ArgumentNullException(nameof(target));
_routeGeneration = routeGeneration ?? string.Empty;
}
public string TransportId => "https-content";
public string Endpoint { get; }
public bool IsVerifiedFor(
string verseId,
string authorityRuntimeId,
string protocolId,
string routeGeneration) =>
string.Equals(_target.VerseId, verseId, StringComparison.Ordinal) &&
string.Equals(_target.AuthorityRuntimeId, authorityRuntimeId, StringComparison.Ordinal) &&
string.Equals(CultMeshProtocols.Content.Value, protocolId, StringComparison.Ordinal) &&
string.Equals(_routeGeneration, routeGeneration, StringComparison.Ordinal);
public async Task CopyChunkToAsync(
CultMeshCdnChunkRef chunk,
Stream destination,
CancellationToken cancellationToken = default)
{
if (_disposed) throw new ObjectDisposedException(nameof(CultMeshHttpsContentTransport));
if (chunk == null) throw new ArgumentNullException(nameof(chunk));
if (destination == null) throw new ArgumentNullException(nameof(destination));
if (!destination.CanWrite) throw new ArgumentException("Content destination must be writable.", nameof(destination));
var requestUri = BuildChunkUri(Endpoint, chunk);
using var response = await _client.GetAsync(
requestUri,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength is long contentLength &&
contentLength != chunk.SizeBytes)
{
throw new InvalidDataException(
$"HTTPS content length {contentLength} did not match advertised chunk size {chunk.SizeBytes}.");
}
using var source = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
await source.CopyToAsync(destination, 81920, cancellationToken).ConfigureAwait(false);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_client.Dispose();
}
private static Uri BuildChunkUri(string endpoint, CultMeshCdnChunkRef chunk)
{
var builder = new UriBuilder(endpoint);
var separator = string.IsNullOrWhiteSpace(builder.Query) ? string.Empty : builder.Query.TrimStart('?') + "&";
builder.Query = separator +
"chunkHash=" + Uri.EscapeDataString(CultMeshCdn.NormalizeHash(chunk.ChunkHash, nameof(chunk.ChunkHash))) +
"&recordKey=" + Uri.EscapeDataString(chunk.RecordKey ?? string.Empty);
return builder.Uri;
}
}
}

View file

@ -59,7 +59,7 @@ namespace GameCult.Mesh
bool CanConnect(CultMeshTransportCandidate candidate);
Task<ICultMeshRealtimeTransport> ConnectAsync(
CultMeshTransportCandidate candidate,
CultMeshEndpointId endpointId,
CultMeshSessionTarget target,
CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,283 @@
using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Networking;
namespace GameCult.Mesh
{
/// <summary>
/// Optional transport proof surfaced by connectors that already authenticate
/// the peer during their native handshake. Other schema clients must complete
/// the portable CultMesh handshake before use.
/// </summary>
public interface ICultMeshVerifiedSchemaClient
{
bool IsVerifiedFor(
string verseId,
string authorityRuntimeId,
string protocolId,
string routeGeneration);
}
/// <summary>
/// Proof exposed by content and realtime transports after their native or
/// portable handshake has bound the physical channel to an Odin route.
/// </summary>
public interface ICultMeshVerifiedTransport
{
bool IsVerifiedFor(
string verseId,
string authorityRuntimeId,
string protocolId,
string routeGeneration);
}
internal static class CultMeshTransportIdentity
{
public static void RequireVerified(
object transport,
CultMeshSessionTarget target,
CultMeshProtocolId protocol,
CultMeshTransportCandidate candidate)
{
if (transport is ICultMeshVerifiedTransport verified &&
verified.IsVerifiedFor(
target.VerseId,
target.AuthorityRuntimeId,
protocol.Value,
candidate.Generation))
return;
throw new CultMeshSessionException(new CultMeshSessionFailure(
CultMeshSessionFailureReason.Authority,
$"Transport did not prove CultMesh authority '{target}' for protocol '{protocol}'.",
candidate.Endpoint));
}
}
/// <summary>
/// Owns the server side of the transport-neutral CultMesh session identity
/// handshake. Application messages are not authority proof; providers attach
/// this gate to every schema transport they advertise.
/// </summary>
public sealed class CultMeshSessionIdentityServer : IDisposable
{
private readonly ICultNetSchemaServer _server;
private readonly string _authorityRuntimeId;
private readonly HashSet<string> _verseIds;
private readonly HashSet<string> _protocolIds;
private readonly HashSet<string> _routeGenerations;
private readonly Dictionary<string, CultMeshSessionProofSigner> _proofSigners;
private readonly ConcurrentDictionary<ICultNetSchemaServerPeer, string> _acceptedPeers = new();
private readonly Func<CultMeshSessionOpenMessage, ICultNetSchemaServerPeer, Task> _handler;
private readonly ICultNetSchemaServerPeerLifecycle? _peerLifecycle;
private bool _disposed;
public CultMeshSessionIdentityServer(
ICultNetSchemaServer server,
string authorityRuntimeId,
IEnumerable<string> verseIds,
IEnumerable<string> protocolIds,
IEnumerable<string> routeGenerations,
IEnumerable<CultMeshSessionProofSigner>? proofSigners = null)
{
_server = server ?? throw new ArgumentNullException(nameof(server));
_authorityRuntimeId = Require(authorityRuntimeId, nameof(authorityRuntimeId));
_verseIds = Clean(verseIds, nameof(verseIds));
_protocolIds = Clean(protocolIds, nameof(protocolIds));
_routeGenerations = Clean(routeGenerations, nameof(routeGenerations));
_proofSigners = (proofSigners ?? Array.Empty<CultMeshSessionProofSigner>())
.ToDictionary(signer => signer.Route.Generation, StringComparer.Ordinal);
_handler = HandleAsync;
_server.OnCultNet(_handler);
_peerLifecycle = server as ICultNetSchemaServerPeerLifecycle;
if (_peerLifecycle != null)
_peerLifecycle.PeerDisconnected += ForgetPeer;
}
/// <summary>
/// Resolves the runtime identity established by this peer's accepted session-open handshake.
/// Application ingress must use this transport-bound value instead of trusting payload identity fields.
/// </summary>
public bool TryGetSourceRuntimeId(ICultNetSchemaServerPeer peer, out string sourceRuntimeId) =>
_acceptedPeers.TryGetValue(peer, out sourceRuntimeId!);
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_server.RemoveCultNetMessageListener<CultMeshSessionOpenMessage>(_handler);
if (_peerLifecycle != null)
_peerLifecycle.PeerDisconnected -= ForgetPeer;
_acceptedPeers.Clear();
}
private Task HandleAsync(CultMeshSessionOpenMessage request, ICultNetSchemaServerPeer peer)
{
var error = Validate(request);
if (error == null)
error = BindPeer(peer, request.SourceRuntimeId);
_proofSigners.TryGetValue(request.RouteGeneration ?? string.Empty, out var signer);
peer.SendCultNet(new CultMeshSessionAcceptedMessage
{
MessageId = request.MessageId ?? string.Empty,
Accepted = error == null,
VerseId = request.VerseId ?? string.Empty,
AuthorityRuntimeId = _authorityRuntimeId,
ProtocolId = request.ProtocolId ?? string.Empty,
RouteGeneration = request.RouteGeneration ?? string.Empty,
ClientNonce = request.ClientNonce ?? string.Empty,
ProviderKeyId = error == null ? signer?.ProviderKeyId ?? string.Empty : string.Empty,
ProviderSignature = error == null && signer != null ? signer.Sign(request) : string.Empty,
Error = error
});
return Task.CompletedTask;
}
private void ForgetPeer(ICultNetSchemaServerPeer peer) => _acceptedPeers.TryRemove(peer, out _);
private string? BindPeer(ICultNetSchemaServerPeer peer, string sourceRuntimeId)
{
if (_acceptedPeers.TryGetValue(peer, out var established))
return string.Equals(established, sourceRuntimeId, StringComparison.Ordinal)
? null
: "session-source-runtime-rebind-forbidden";
return _acceptedPeers.TryAdd(peer, sourceRuntimeId)
? null
: BindPeer(peer, sourceRuntimeId);
}
private string? Validate(CultMeshSessionOpenMessage request)
{
if (string.IsNullOrWhiteSpace(request.MessageId)) return "session-message-id-required";
if (string.IsNullOrWhiteSpace(request.SourceRuntimeId)) return "source-runtime-id-required";
if (!TryNonce(request.ClientNonce)) return "client-nonce-invalid";
if (!string.Equals(request.AuthorityRuntimeId, _authorityRuntimeId, StringComparison.Ordinal))
return "target-runtime-mismatch";
if (!_verseIds.Contains(request.VerseId ?? string.Empty)) return "verse-not-served";
if (!_protocolIds.Contains(request.ProtocolId ?? string.Empty)) return "protocol-not-served";
if (!_routeGenerations.Contains(request.RouteGeneration ?? string.Empty)) return "route-generation-not-served";
return null;
}
private static bool TryNonce(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return false;
try { return Convert.FromBase64String(value).Length == 32; }
catch (FormatException) { return false; }
}
private static HashSet<string> Clean(IEnumerable<string> values, string name)
{
if (values == null) throw new ArgumentNullException(name);
var result = new HashSet<string>(
values.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value.Trim()),
StringComparer.Ordinal);
if (result.Count == 0) throw new ArgumentException("At least one identity is required.", name);
return result;
}
private static string Require(string value, string name) =>
string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value must be non-empty.", name)
: value.Trim();
}
internal static class CultMeshSessionIdentityClient
{
public static async Task VerifyAsync(
ICultNetSchemaClient client,
string sourceRuntimeId,
CultMeshSessionTarget target,
CultMeshProtocolId protocol,
CultMeshTransportCandidate candidate,
CultMeshAuthorityTrustPolicy? trust,
DateTimeOffset now,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
if (client == null) throw new ArgumentNullException(nameof(client));
if (candidate == null) throw new ArgumentNullException(nameof(candidate));
var route = candidate.AuthorityRoute ?? new CultMeshAuthorityRoute(
target.AuthorityRuntimeId,
candidate.Endpoint,
new[] { protocol.Value },
candidate.Priority,
candidate.Generation);
var verifiedRoute = candidate.VerifiedAuthority ??
(trust ?? throw new CultMeshSessionException(new CultMeshSessionFailure(
CultMeshSessionFailureReason.Authentication,
"No consumer trust policy verified this CultMesh route.",
candidate.Endpoint))).Verify(target.VerseId, route, now);
if (client is ICultMeshVerifiedSchemaClient verified)
{
if (!verified.IsVerifiedFor(
target.VerseId,
target.AuthorityRuntimeId,
protocol.Value,
candidate.Generation))
{
throw new CultMeshSessionException(new CultMeshSessionFailure(
CultMeshSessionFailureReason.Authority,
$"Native transport identity did not match CultMesh target '{target}' for '{protocol}'."));
}
// Native identity is sufficient only for an explicitly selected
// loopback development route. Remote schema sessions still prove
// possession of the provider key certified by Odin.
if (verifiedRoute.IsLocalDevelopment) return;
}
var messageId = Guid.NewGuid().ToString("N");
var nonce = new byte[32];
using (var random = RandomNumberGenerator.Create()) random.GetBytes(nonce);
var completion = new TaskCompletionSource<CultMeshSessionAcceptedMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
client.OnCultNet<CultMeshSessionAcceptedMessage>(response =>
{
if (string.Equals(response.MessageId, messageId, StringComparison.Ordinal))
completion.TrySetResult(response);
});
var request = new CultMeshSessionOpenMessage
{
MessageId = messageId,
SourceRuntimeId = sourceRuntimeId,
VerseId = target.VerseId,
AuthorityRuntimeId = target.AuthorityRuntimeId,
ProtocolId = protocol.Value,
RouteGeneration = candidate.Generation,
ClientNonce = Convert.ToBase64String(nonce)
};
client.SendCultNet(request);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(timeout);
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using (deadline.Token.Register(() => cancelled.TrySetResult(true)))
{
if (await Task.WhenAny(completion.Task, cancelled.Task).ConfigureAwait(false) != completion.Task)
{
cancellationToken.ThrowIfCancellationRequested();
throw new CultMeshSessionException(new CultMeshSessionFailure(
CultMeshSessionFailureReason.Timeout,
$"Timed out verifying authority runtime '{target.AuthorityRuntimeId}'."));
}
}
var accepted = await completion.Task.ConfigureAwait(false);
if (!accepted.Accepted ||
!string.Equals(accepted.VerseId, target.VerseId, StringComparison.Ordinal) ||
!string.Equals(accepted.AuthorityRuntimeId, target.AuthorityRuntimeId, StringComparison.Ordinal) ||
!string.Equals(accepted.ProtocolId, protocol.Value, StringComparison.Ordinal) ||
!string.Equals(accepted.RouteGeneration, candidate.Generation, StringComparison.Ordinal) ||
!CultMeshAuthorityProof.VerifySession(request, accepted, verifiedRoute))
{
throw new CultMeshSessionException(new CultMeshSessionFailure(
CultMeshSessionFailureReason.Authority,
$"Route did not prove CultMesh authority '{target}' for protocol '{protocol}': " +
(accepted.Error ?? "identity-mismatch")));
}
}
}
}

View file

@ -9,14 +9,14 @@ using R3;
namespace GameCult.Mesh
{
public sealed class CultMeshEndpointId : IEquatable<CultMeshEndpointId>
public sealed class CultMeshRuntimeId : IEquatable<CultMeshRuntimeId>
{
private CultMeshEndpointId(string value) { Value = value; }
private CultMeshRuntimeId(string value) { Value = value; }
public string Value { get; }
public static CultMeshEndpointId Parse(string value) => new CultMeshEndpointId(
string.IsNullOrWhiteSpace(value) ? throw new FormatException("CultMesh endpoint identity must be non-empty.") : value.Trim());
public bool Equals(CultMeshEndpointId? other) => other != null && string.Equals(Value, other.Value, StringComparison.Ordinal);
public override bool Equals(object? obj) => Equals(obj as CultMeshEndpointId);
public static CultMeshRuntimeId Parse(string value) => new CultMeshRuntimeId(
string.IsNullOrWhiteSpace(value) ? throw new FormatException("CultMesh runtime identity must be non-empty.") : value.Trim());
public bool Equals(CultMeshRuntimeId? other) => other != null && string.Equals(Value, other.Value, StringComparison.Ordinal);
public override bool Equals(object? obj) => Equals(obj as CultMeshRuntimeId);
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value);
public override string ToString() => Value;
}
@ -28,31 +28,32 @@ namespace GameCult.Mesh
public sealed class CultMeshSessionTarget : IEquatable<CultMeshSessionTarget>
{
/// <summary>Creates one stable provider target inside one Verse.</summary>
public CultMeshSessionTarget(string verseId, string providerRuntimeId)
public CultMeshSessionTarget(string verseId, string authorityRuntimeId)
{
VerseId = string.IsNullOrWhiteSpace(verseId)
? throw new ArgumentException("Verse identity is required.", nameof(verseId))
: verseId.Trim();
ProviderEndpointId = CultMeshEndpointId.Parse(providerRuntimeId);
ProviderRuntimeId = ProviderEndpointId.Value;
AuthorityRuntimeId = CultMeshRuntimeId.Parse(authorityRuntimeId).Value;
}
/// <summary>Gets the stable Verse identity.</summary>
public string VerseId { get; }
/// <summary>Gets the stable provider runtime identity advertised by that Verse.</summary>
public string ProviderRuntimeId { get; }
internal CultMeshEndpointId ProviderEndpointId { get; }
internal string SessionKey => VerseId + "\u001f" + ProviderRuntimeId;
/// <summary>Gets the stable authority runtime identity advertised by that Verse.</summary>
public string AuthorityRuntimeId { get; }
/// <summary>Compatibility projection. Use <see cref="AuthorityRuntimeId"/>.</summary>
[Obsolete("Use AuthorityRuntimeId. Provider identity is not a physical endpoint.")]
public string ProviderRuntimeId => AuthorityRuntimeId;
internal string SessionKey => VerseId + "\u001f" + AuthorityRuntimeId;
public bool Equals(CultMeshSessionTarget? other) =>
other != null &&
string.Equals(VerseId, other.VerseId, StringComparison.Ordinal) &&
string.Equals(ProviderRuntimeId, other.ProviderRuntimeId, StringComparison.Ordinal);
string.Equals(AuthorityRuntimeId, other.AuthorityRuntimeId, StringComparison.Ordinal);
public override bool Equals(object? obj) => Equals(obj as CultMeshSessionTarget);
public override int GetHashCode() => HashCode.Combine(
StringComparer.Ordinal.GetHashCode(VerseId),
StringComparer.Ordinal.GetHashCode(ProviderRuntimeId));
public override string ToString() => VerseId + "/" + ProviderRuntimeId;
StringComparer.Ordinal.GetHashCode(AuthorityRuntimeId));
public override string ToString() => VerseId + "/" + AuthorityRuntimeId;
}
public sealed class CultMeshProtocolId : IEquatable<CultMeshProtocolId>
@ -80,15 +81,33 @@ namespace GameCult.Mesh
public sealed class CultMeshTransportCandidate
{
public CultMeshTransportCandidate(string endpoint, CultMeshTransportPathKind pathKind = CultMeshTransportPathKind.Direct, int priority = 0)
public CultMeshTransportCandidate(
string endpoint,
CultMeshTransportPathKind pathKind = CultMeshTransportPathKind.Direct,
int priority = 0,
string authorityRuntimeId = "",
string generation = "",
CultMeshAuthorityRoute? authorityRoute = null)
{
Endpoint = string.IsNullOrWhiteSpace(endpoint) ? throw new ArgumentException("Physical endpoint is required.", nameof(endpoint)) : endpoint;
PathKind = pathKind;
Priority = priority;
AuthorityRuntimeId = authorityRuntimeId?.Trim() ?? string.Empty;
Generation = generation?.Trim() ?? string.Empty;
AuthorityRoute = authorityRoute;
}
public string Endpoint { get; }
public CultMeshTransportPathKind PathKind { get; }
public int Priority { get; }
public string AuthorityRuntimeId { get; }
public string Generation { get; }
public CultMeshAuthorityRoute? AuthorityRoute { get; }
internal CultMeshVerifiedAuthorityRoute? VerifiedAuthority { get; private set; }
internal CultMeshTransportCandidate WithVerifiedAuthority(CultMeshVerifiedAuthorityRoute verified)
{
VerifiedAuthority = verified ?? throw new ArgumentNullException(nameof(verified));
return this;
}
}
public enum CultMeshSessionStatus { Connecting, Online, Degraded, Reconnecting, Offline }
@ -522,6 +541,11 @@ namespace GameCult.Mesh
public ICultMeshClock Clock { get; set; } = CultMeshSystemClock.Instance;
public ICultMeshDiagnosticSink Diagnostics { get; set; } = CultMeshNullDiagnosticSink.Instance;
public int MaxRacedCandidates { get; set; } = 2;
public string RuntimeId { get; set; } = "cultmesh-client";
public TimeSpan IdentityHandshakeTimeout { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>Gets or sets the consumer-owned trust roots and explicit local-development policy.</summary>
public CultMeshAuthorityTrustPolicy Trust { get; set; } = new(
CultMeshAuthorityTrustMode.AuthenticatedRemote);
}
public sealed class CultMeshSessionManager : IDisposable
@ -581,6 +605,8 @@ namespace GameCult.Mesh
throw new ArgumentException("Realtime transport connectors cannot contain null entries.", nameof(realtimeConnectors));
_options = options ?? new CultMeshSessionManagerOptions();
if (_options.MaxRacedCandidates <= 0) throw new ArgumentOutOfRangeException(nameof(options));
if (string.IsNullOrWhiteSpace(_options.RuntimeId)) throw new ArgumentException("Client runtime identity is required.", nameof(options));
if (_options.IdentityHandshakeTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(options));
}
public async Task<CultMeshSession> ConnectAsync(
@ -661,14 +687,10 @@ namespace GameCult.Mesh
"No streaming content connectors are configured. Register the TCP content connector or the explicit legacy RUDP connector.");
var discovery = await _discovery.ResolveAsync(new CultMeshDiscoveryQuery(
target.ProviderRuntimeId,
target.AuthorityRuntimeId,
new[] { target.VerseId },
target.ProviderRuntimeId)).ConfigureAwait(false);
var candidates = discovery.Candidates
.SelectMany(candidate => candidate.Descriptor.DiscoveryEndpoints)
.Distinct(StringComparer.Ordinal)
.Select(endpoint => new CultMeshTransportCandidate(endpoint))
.ToArray();
target.AuthorityRuntimeId)).ConfigureAwait(false);
var candidates = BoundCandidates(discovery, target, CultMeshProtocols.Content);
var routes = candidates
.SelectMany(candidate => _contentConnectors
.Where(connector => connector.CanConnect(candidate))
@ -685,7 +707,7 @@ namespace GameCult.Mesh
foreach (var tier in routes.GroupBy(route => route.Connector.Priority).OrderBy(group => group.Key))
{
var attempts = tier.Take(_options.MaxRacedCandidates)
.Select(route => ConnectContentCandidateAsync(target.ProviderEndpointId, route))
.Select(route => ConnectContentCandidateAsync(target, route))
.ToList();
while (attempts.Count > 0)
{
@ -709,18 +731,33 @@ namespace GameCult.Mesh
}
var last = failures.LastOrDefault();
var reason = last is CultMeshSessionException typed
? typed.Failure.Reason
: last is TimeoutException
? CultMeshSessionFailureReason.Timeout
: CultMeshSessionFailureReason.Transport;
throw Failure(
last is TimeoutException ? CultMeshSessionFailureReason.Timeout : CultMeshSessionFailureReason.Transport,
reason,
$"No streaming content path connected for '{target}'.",
last);
}
private static async Task<ConnectedContentPath> ConnectContentCandidateAsync(
CultMeshEndpointId endpointId,
CultMeshSessionTarget target,
ContentRoute route)
{
var transport = await route.Connector.ConnectAsync(route.Candidate, endpointId).ConfigureAwait(false);
return new ConnectedContentPath(route.Candidate, transport);
var transport = await route.Connector.ConnectAsync(route.Candidate, target).ConfigureAwait(false);
try
{
CultMeshTransportIdentity.RequireVerified(
transport, target, CultMeshProtocols.Content, route.Candidate);
return new ConnectedContentPath(route.Candidate, transport);
}
catch
{
transport.Dispose();
throw;
}
}
private void InvalidateContentSession(string key, CultMeshContentSession session)
@ -790,13 +827,10 @@ namespace GameCult.Mesh
"No realtime state connectors are configured. Register a QUIC connector explicitly.");
var discovery = await _discovery.ResolveAsync(new CultMeshDiscoveryQuery(
target.ProviderRuntimeId,
target.AuthorityRuntimeId,
new[] { target.VerseId },
target.ProviderRuntimeId)).ConfigureAwait(false);
var routes = discovery.Candidates
.SelectMany(candidate => candidate.Descriptor.DiscoveryEndpoints)
.Distinct(StringComparer.Ordinal)
.Select(endpoint => new CultMeshTransportCandidate(endpoint))
target.AuthorityRuntimeId)).ConfigureAwait(false);
var routes = BoundCandidates(discovery, target, CultMeshProtocols.RealtimeState)
.SelectMany(candidate => _realtimeConnectors
.Where(connector => connector.CanConnect(candidate))
.Select(connector => new RealtimeRoute(candidate, connector)))
@ -812,7 +846,7 @@ namespace GameCult.Mesh
foreach (var tier in routes.GroupBy(route => route.Connector.Priority).OrderBy(group => group.Key))
{
var attempts = tier.Take(_options.MaxRacedCandidates)
.Select(route => ConnectRealtimeCandidateAsync(target.ProviderEndpointId, route))
.Select(route => ConnectRealtimeCandidateAsync(target, route))
.ToList();
while (attempts.Count > 0)
{
@ -836,18 +870,33 @@ namespace GameCult.Mesh
}
var last = failures.LastOrDefault();
var reason = last is CultMeshSessionException typed
? typed.Failure.Reason
: last is TimeoutException
? CultMeshSessionFailureReason.Timeout
: CultMeshSessionFailureReason.Transport;
throw Failure(
last is TimeoutException ? CultMeshSessionFailureReason.Timeout : CultMeshSessionFailureReason.Transport,
reason,
$"No realtime state path connected for '{target}'.",
last);
}
private static async Task<ConnectedRealtimePath> ConnectRealtimeCandidateAsync(
CultMeshEndpointId endpointId,
CultMeshSessionTarget target,
RealtimeRoute route)
{
var transport = await route.Connector.ConnectAsync(route.Candidate, endpointId).ConfigureAwait(false);
return new ConnectedRealtimePath(route.Candidate, transport);
var transport = await route.Connector.ConnectAsync(route.Candidate, target).ConfigureAwait(false);
try
{
CultMeshTransportIdentity.RequireVerified(
transport, target, CultMeshProtocols.RealtimeState, route.Candidate);
return new ConnectedRealtimePath(route.Candidate, transport);
}
catch
{
transport.Dispose();
throw;
}
}
private void InvalidateRealtimeSession(string key, CultMeshRealtimeSession session)
@ -880,13 +929,10 @@ namespace GameCult.Mesh
private async Task<ConnectedPath> ResolveConnectedPathAsync(CultMeshSessionTarget target, CultMeshProtocolId protocol)
{
var discovery = await _discovery.ResolveAsync(new CultMeshDiscoveryQuery(
target.ProviderRuntimeId,
target.AuthorityRuntimeId,
new[] { target.VerseId },
target.ProviderRuntimeId)).ConfigureAwait(false);
var candidates = discovery.Candidates.SelectMany(candidate => candidate.Descriptor.DiscoveryEndpoints)
.Distinct(StringComparer.Ordinal)
.Select(endpoint => new CultMeshTransportCandidate(endpoint))
.ToArray();
target.AuthorityRuntimeId)).ConfigureAwait(false);
var candidates = BoundCandidates(discovery, target, protocol);
var routes = candidates
.SelectMany(candidate => _connectors
.Where(connector => connector.CanConnect(candidate))
@ -908,7 +954,7 @@ namespace GameCult.Mesh
foreach (var tier in routes.GroupBy(route => route.Connector.Priority).OrderBy(group => group.Key))
{
var attempts = tier.Take(_options.MaxRacedCandidates)
.Select(route => ConnectCandidateAsync(route, protocol))
.Select(route => ConnectCandidateAsync(target, route, protocol))
.ToList();
while (attempts.Count > 0)
{
@ -925,7 +971,12 @@ namespace GameCult.Mesh
}
}
var last = failures.LastOrDefault();
throw Failure(last is TimeoutException ? CultMeshSessionFailureReason.Timeout : CultMeshSessionFailureReason.Transport,
var reason = last is CultMeshSessionException typed
? typed.Failure.Reason
: last is TimeoutException
? CultMeshSessionFailureReason.Timeout
: CultMeshSessionFailureReason.Transport;
throw Failure(reason,
$"No transport path connected for '{target}' and protocol '{protocol}'.", last);
}
@ -960,9 +1011,9 @@ namespace GameCult.Mesh
try
{
_discovery.Invalidate(new CultMeshDiscoveryQuery(
session.Target.ProviderRuntimeId,
session.Target.AuthorityRuntimeId,
new[] { session.Target.VerseId },
session.Target.ProviderRuntimeId));
session.Target.AuthorityRuntimeId));
var replacement = await ResolveConnectedPathAsync(session.Target, session.Protocol).ConfigureAwait(false);
session.ReplacePhysicalChannel(replacement.Client);
session.Transition(new CultMeshSessionState(
@ -987,10 +1038,30 @@ namespace GameCult.Mesh
}
}
private static async Task<ConnectedPath> ConnectCandidateAsync(SchemaRoute route, CultMeshProtocolId protocol)
private async Task<ConnectedPath> ConnectCandidateAsync(
CultMeshSessionTarget target,
SchemaRoute route,
CultMeshProtocolId protocol)
{
var client = await route.Connector.ConnectAsync(route.Candidate, protocol).ConfigureAwait(false);
return new ConnectedPath(route.Candidate, client);
try
{
await CultMeshSessionIdentityClient.VerifyAsync(
client,
_options.RuntimeId,
target,
protocol,
route.Candidate,
_options.Trust,
_options.Clock.UtcNow,
_options.IdentityHandshakeTimeout).ConfigureAwait(false);
return new ConnectedPath(route.Candidate, client);
}
catch
{
client.Dispose();
throw;
}
}
private static async Task<T> AwaitForCallerAsync<T>(Task<T> shared, CancellationToken cancellationToken)
@ -1008,12 +1079,62 @@ namespace GameCult.Mesh
private CultMeshSessionException Failure(CultMeshSessionFailureReason reason, string message, Exception? inner = null) =>
new CultMeshSessionException(new CultMeshSessionFailure(reason, message), inner);
private CultMeshTransportCandidate[] BoundCandidates(
CultMeshDiscoveryState discovery,
CultMeshSessionTarget target,
CultMeshProtocolId protocol)
{
var routes = discovery.Candidates
.SelectMany(candidate => candidate.Descriptor.AuthorityRoutes)
.Where(route => string.Equals(
route.AuthorityRuntimeId,
target.AuthorityRuntimeId,
StringComparison.Ordinal))
.Where(route => route.Supports(protocol))
.ToArray();
var verified = new List<CultMeshTransportCandidate>();
var rejected = new List<Exception>();
foreach (var route in routes)
{
try
{
verified.Add(new CultMeshTransportCandidate(
route.Endpoint,
priority: route.Priority,
authorityRuntimeId: route.AuthorityRuntimeId,
generation: route.Generation,
authorityRoute: route)
.WithVerifiedAuthority(_options.Trust.Verify(
target.VerseId,
route,
_options.Clock.UtcNow)));
}
catch (CultMeshSessionException error)
{
rejected.Add(error);
}
}
if (verified.Count == 0 && rejected.Count > 0)
{
throw Failure(
CultMeshSessionFailureReason.Authentication,
$"No trusted route remained for CultMesh target '{target}' and protocol '{protocol}'.",
new AggregateException(rejected));
}
return verified
.GroupBy(candidate => candidate.Endpoint, StringComparer.Ordinal)
.Select(group => group.OrderBy(candidate => candidate.Priority).First())
.OrderBy(candidate => candidate.Priority)
.ThenBy(candidate => candidate.Endpoint, StringComparer.Ordinal)
.ToArray();
}
private void Emit(CultMeshSessionTarget target, CultMeshProtocolId protocol, string state, string path)
{
_options.Diagnostics.Emit(new CultMeshDiagnosticEvent(
Interlocked.Increment(ref _diagnosticSequence), _options.Clock.UtcNow,
CultMeshReliabilityOrgan.Session, CultMeshDiagnosticKind.PathChanged,
target.SessionKey + ":" + protocol.Value, target.ProviderRuntimeId, state,
target.SessionKey + ":" + protocol.Value, target.AuthorityRuntimeId, state,
sourceId: "session-manager", endpoint: path));
}

View file

@ -102,7 +102,7 @@ namespace GameCult.Mesh
CultNetDocumentRegistry? registry)
{
_sharedSession = session ?? throw new ArgumentNullException(nameof(session));
_endpoint = session.State.Path?.Endpoint ?? session.Target.ProviderRuntimeId;
_endpoint = session.State.Path?.Endpoint ?? session.Target.AuthorityRuntimeId;
_defaults = CultMesh.CloneSnapshotRequestOptions(options);
_registry = registry ?? new CultNetDocumentRegistry();
_client = session.Channel;

View file

@ -11,6 +11,22 @@ using GameCult.Networking;
namespace GameCult.Mesh
{
/// <summary>Identity served by one dedicated content listener.</summary>
public sealed class CultMeshTcpContentServerOptions
{
public string VerseId { get; set; } = string.Empty;
public string AuthorityRuntimeId { get; set; } = string.Empty;
public string RouteGeneration { get; set; } = string.Empty;
public CultMeshSessionProofSigner? ProofSigner { get; set; }
internal void Validate()
{
if (string.IsNullOrWhiteSpace(VerseId)) throw new InvalidOperationException("Content Verse identity is required.");
if (string.IsNullOrWhiteSpace(AuthorityRuntimeId)) throw new InvalidOperationException("Content authority runtime identity is required.");
if (string.IsNullOrWhiteSpace(RouteGeneration)) throw new InvalidOperationException("Content route generation is required.");
}
}
/// <summary>Creates dedicated TCP streams for immutable CultMesh content.</summary>
public sealed class CultMeshTcpContentTransportConnector : ICultMeshContentTransportConnector
{
@ -33,11 +49,11 @@ namespace GameCult.Mesh
public async Task<ICultMeshContentTransport> ConnectAsync(
CultMeshTransportCandidate candidate,
CultMeshEndpointId endpointId,
CultMeshSessionTarget target,
CancellationToken cancellationToken = default)
{
if (candidate == null) throw new ArgumentNullException(nameof(candidate));
if (endpointId == null) throw new ArgumentNullException(nameof(endpointId));
if (target == null) throw new ArgumentNullException(nameof(target));
if (!TryParseEndpoint(candidate.Endpoint, out var host, out var port))
throw new NotSupportedException($"TCP content connector does not support '{candidate.Endpoint}'.");
@ -48,8 +64,10 @@ namespace GameCult.Mesh
{
await CultMeshTcpContentWire.AwaitAsync(client.ConnectAsync(host, port), timeout.Token)
.ConfigureAwait(false);
await CultMeshTcpContentWire.VerifyAuthorityAsync(
client.GetStream(), target, candidate, timeout.Token).ConfigureAwait(false);
Interlocked.Increment(ref _connectCount);
return new CultMeshTcpContentTransport(candidate.Endpoint, client);
return new CultMeshTcpContentTransport(candidate.Endpoint, client, target, candidate.Generation);
}
catch
{
@ -72,7 +90,7 @@ namespace GameCult.Mesh
}
}
internal sealed class CultMeshTcpContentTransport : ICultMeshContentTransport
internal sealed class CultMeshTcpContentTransport : ICultMeshContentTransport, ICultMeshVerifiedTransport
{
private readonly TcpClient _client;
private readonly NetworkStream _stream;
@ -80,12 +98,22 @@ namespace GameCult.Mesh
private readonly ConcurrentDictionary<string, PendingCopy> _pending = new(StringComparer.Ordinal);
private readonly CancellationTokenSource _shutdown = new();
private readonly Task _receiveLoop;
private readonly string _verseId;
private readonly string _authorityRuntimeId;
private readonly string _routeGeneration;
private bool _disposed;
public CultMeshTcpContentTransport(string endpoint, TcpClient client)
public CultMeshTcpContentTransport(
string endpoint,
TcpClient client,
CultMeshSessionTarget target,
string routeGeneration)
{
Endpoint = endpoint;
_client = client ?? throw new ArgumentNullException(nameof(client));
_verseId = target?.VerseId ?? throw new ArgumentNullException(nameof(target));
_authorityRuntimeId = target.AuthorityRuntimeId;
_routeGeneration = routeGeneration ?? string.Empty;
_stream = client.GetStream();
_receiveLoop = ReceiveLoopAsync(_shutdown.Token);
}
@ -93,6 +121,16 @@ namespace GameCult.Mesh
public string TransportId => "tcp-content";
public string Endpoint { get; }
public bool IsVerifiedFor(
string verseId,
string authorityRuntimeId,
string protocolId,
string routeGeneration) =>
string.Equals(_verseId, verseId, StringComparison.Ordinal) &&
string.Equals(_authorityRuntimeId, authorityRuntimeId, StringComparison.Ordinal) &&
string.Equals(CultMeshProtocols.Content.Value, protocolId, StringComparison.Ordinal) &&
string.Equals(_routeGeneration, routeGeneration, StringComparison.Ordinal);
public async Task CopyChunkToAsync(
CultMeshCdnChunkRef chunk,
Stream destination,
@ -212,6 +250,7 @@ namespace GameCult.Mesh
private readonly TcpListener _listener;
private readonly Func<string, CultMeshCdnArtifactChunk?> _resolveChunk;
private readonly Func<string, bool> _canServeHash;
private readonly CultMeshTcpContentServerOptions _options;
private readonly CancellationTokenSource _shutdown = new();
private readonly ConcurrentDictionary<TcpClient, byte> _clients = new();
private readonly Task _acceptLoop;
@ -221,8 +260,9 @@ namespace GameCult.Mesh
public CultMeshTcpContentServer(
TcpListener listener,
CultCache content,
CultMeshTcpContentServerOptions options,
Func<string, bool>? canServeHash = null)
: this(listener, CreateCacheResolver(content), canServeHash)
: this(listener, CreateCacheResolver(content), options, canServeHash)
{
}
@ -234,10 +274,13 @@ namespace GameCult.Mesh
public CultMeshTcpContentServer(
TcpListener listener,
Func<string, CultMeshCdnArtifactChunk?> resolveChunk,
CultMeshTcpContentServerOptions options,
Func<string, bool>? canServeHash = null)
{
_listener = listener ?? throw new ArgumentNullException(nameof(listener));
_resolveChunk = resolveChunk ?? throw new ArgumentNullException(nameof(resolveChunk));
_options = options ?? throw new ArgumentNullException(nameof(options));
_options.Validate();
_canServeHash = canServeHash ?? (_ => true);
_listener.Start();
_acceptLoop = AcceptLoopAsync(_shutdown.Token);
@ -278,6 +321,7 @@ namespace GameCult.Mesh
try
{
using var stream = client.GetStream();
if (!await AcceptAuthorityAsync(stream, cancellationToken).ConfigureAwait(false)) return;
while (!cancellationToken.IsCancellationRequested)
{
var requestBytes = await CultMeshTcpContentWire.ReadFrameAsync(stream, cancellationToken).ConfigureAwait(false);
@ -296,6 +340,47 @@ namespace GameCult.Mesh
}
}
private async Task<bool> AcceptAuthorityAsync(Stream stream, CancellationToken cancellationToken)
{
var requestBytes = await CultMeshTcpContentWire.ReadFrameAsync(stream, cancellationToken).ConfigureAwait(false);
var request = CultNetSchemaMessageSerialization.Deserialize(requestBytes) as CultMeshSessionOpenMessage
?? throw new InvalidDataException("TCP content session did not begin with an authority handshake.");
var accepted = !string.IsNullOrWhiteSpace(request.MessageId) &&
!string.IsNullOrWhiteSpace(request.SourceRuntimeId) &&
IsNonce(request.ClientNonce) &&
string.Equals(request.VerseId, _options.VerseId, StringComparison.Ordinal) &&
string.Equals(request.AuthorityRuntimeId, _options.AuthorityRuntimeId, StringComparison.Ordinal) &&
string.Equals(request.ProtocolId, CultMeshProtocols.Content.Value, StringComparison.Ordinal) &&
string.Equals(request.RouteGeneration, _options.RouteGeneration, StringComparison.Ordinal);
var response = new CultMeshSessionAcceptedMessage
{
MessageId = request.MessageId,
Accepted = accepted,
VerseId = _options.VerseId,
AuthorityRuntimeId = _options.AuthorityRuntimeId,
ProtocolId = CultMeshProtocols.Content.Value,
RouteGeneration = _options.RouteGeneration,
ClientNonce = request.ClientNonce ?? string.Empty,
ProviderKeyId = accepted ? _options.ProofSigner?.ProviderKeyId ?? string.Empty : string.Empty,
ProviderSignature = accepted && _options.ProofSigner != null
? _options.ProofSigner.Sign(request)
: string.Empty,
Error = accepted ? null : "Content session target does not match this authority route."
};
await CultMeshTcpContentWire.WriteFrameAsync(
stream,
CultNetSchemaMessageSerialization.Serialize(response),
cancellationToken).ConfigureAwait(false);
return accepted;
}
private static bool IsNonce(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return false;
try { return Convert.FromBase64String(value).Length == 32; }
catch (FormatException) { return false; }
}
private async Task SendChunkAsync(
Stream stream,
CultMeshContentChunkRequestMessage request,
@ -391,6 +476,48 @@ namespace GameCult.Mesh
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
public static async Task VerifyAuthorityAsync(
Stream stream,
CultMeshSessionTarget target,
CultMeshTransportCandidate candidate,
CancellationToken cancellationToken)
{
var request = new CultMeshSessionOpenMessage
{
MessageId = Guid.NewGuid().ToString("N"),
SourceRuntimeId = "cultmesh-content-client",
VerseId = target.VerseId,
AuthorityRuntimeId = target.AuthorityRuntimeId,
ProtocolId = CultMeshProtocols.Content.Value,
RouteGeneration = candidate.Generation,
ClientNonce = CreateNonce()
};
await WriteFrameAsync(stream, CultNetSchemaMessageSerialization.Serialize(request), cancellationToken)
.ConfigureAwait(false);
var responseBytes = await ReadFrameAsync(stream, cancellationToken).ConfigureAwait(false);
var response = CultNetSchemaMessageSerialization.Deserialize(responseBytes) as CultMeshSessionAcceptedMessage
?? throw new InvalidDataException("TCP content authority handshake returned an unexpected schema.");
if (!string.Equals(response.MessageId, request.MessageId, StringComparison.Ordinal) ||
!response.Accepted ||
!string.Equals(response.VerseId, target.VerseId, StringComparison.Ordinal) ||
!string.Equals(response.AuthorityRuntimeId, target.AuthorityRuntimeId, StringComparison.Ordinal) ||
!string.Equals(response.ProtocolId, CultMeshProtocols.Content.Value, StringComparison.Ordinal) ||
!string.Equals(response.RouteGeneration, candidate.Generation, StringComparison.Ordinal) ||
candidate.VerifiedAuthority == null ||
!CultMeshAuthorityProof.VerifySession(request, response, candidate.VerifiedAuthority))
throw new CultMeshSessionException(new CultMeshSessionFailure(
CultMeshSessionFailureReason.Authority,
response.Error ?? "TCP content endpoint did not prove the selected authority route.",
candidate.Endpoint));
}
private static string CreateNonce()
{
var nonce = new byte[32];
using (var random = System.Security.Cryptography.RandomNumberGenerator.Create()) random.GetBytes(nonce);
return Convert.ToBase64String(nonce);
}
public static async Task<byte[]> ReadFrameAsync(Stream stream, CancellationToken cancellationToken)
{
var header = new byte[HeaderBytes];

View file

@ -91,6 +91,57 @@ namespace GameCult.Mesh
}
}
/// <summary>
/// Binds one authoritative runtime to one physical route. This relationship is
/// the routing authority; the legacy endpoint/runtime lists are projections only.
/// </summary>
public sealed class CultMeshAuthorityRoute
{
public CultMeshAuthorityRoute(
string authorityRuntimeId,
string endpoint,
IEnumerable<string>? protocolIds = null,
int priority = 0,
string? generation = null,
CultMeshRouteCertificate? certificate = null)
{
AuthorityRuntimeId = RequireNonEmpty(authorityRuntimeId, nameof(authorityRuntimeId));
Endpoint = RequireNonEmpty(endpoint, nameof(endpoint));
ProtocolIds = Clean(protocolIds);
if (priority < 0) throw new ArgumentOutOfRangeException(nameof(priority));
Priority = priority;
Generation = string.IsNullOrWhiteSpace(generation)
? AuthorityRuntimeId + "@" + Endpoint
: generation!.Trim();
Certificate = certificate;
}
public string AuthorityRuntimeId { get; }
public string Endpoint { get; }
public IReadOnlyList<string> ProtocolIds { get; }
public int Priority { get; }
public string Generation { get; }
/// <summary>Gets the optional Odin-signed binding from this route to its provider key.</summary>
public CultMeshRouteCertificate? Certificate { get; }
public bool Supports(CultMeshProtocolId protocol) =>
protocol != null &&
(ProtocolIds.Count == 0 || ProtocolIds.Contains(protocol.Value, StringComparer.Ordinal));
private static string[] Clean(IEnumerable<string>? values) =>
values?.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(value => value, StringComparer.Ordinal)
.ToArray()
?? Array.Empty<string>();
private static string RequireNonEmpty(string value, string paramName) =>
string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value must be non-empty.", paramName)
: value.Trim();
}
/// <summary>
/// Describes one CultMesh Verse: a rule-bearing consensus graph.
/// </summary>
@ -107,14 +158,36 @@ namespace GameCult.Mesh
IEnumerable<string>? discoveryEndpoints = null,
IEnumerable<string>? authorityRuntimeIds = null,
string? parentVerseId = null,
string? description = null)
string? description = null,
IEnumerable<CultMeshAuthorityRoute>? authorityRoutes = null)
{
VerseId = RequireNonEmpty(verseId, nameof(verseId));
DisplayName = RequireNonEmpty(displayName, nameof(displayName));
AuthorityModel = authorityModel;
Compatibility = compatibility ?? throw new ArgumentNullException(nameof(compatibility));
DiscoveryEndpoints = Clean(discoveryEndpoints);
AuthorityRuntimeIds = Clean(authorityRuntimeIds);
var legacyEndpoints = Clean(discoveryEndpoints);
var legacyRuntimeIds = Clean(authorityRuntimeIds);
var suppliedRoutes = authorityRoutes?.ToArray();
if (suppliedRoutes?.Any(route => route == null) == true)
throw new ArgumentException("Authority routes cannot contain null.", nameof(authorityRoutes));
if (suppliedRoutes != null && (legacyEndpoints.Length > 0 || legacyRuntimeIds.Length > 0))
throw new ArgumentException(
"Supply bound authority routes or legacy endpoint/runtime lists, never both.",
nameof(authorityRoutes));
if (suppliedRoutes == null && legacyEndpoints.Length > 0 && legacyRuntimeIds.Length > 1)
throw new InvalidOperationException(
"Legacy Verse routing with multiple authority runtimes is ambiguous. " +
"Publish explicit CultMeshAuthorityRoute bindings.");
AuthorityRoutes = NormalizeRoutes(suppliedRoutes ??
(legacyRuntimeIds.Length == 1
? legacyEndpoints.Select(endpoint => new CultMeshAuthorityRoute(legacyRuntimeIds[0], endpoint))
: Array.Empty<CultMeshAuthorityRoute>()));
DiscoveryEndpoints = AuthorityRoutes.Count > 0
? AuthorityRoutes.Select(route => route.Endpoint).Distinct(StringComparer.Ordinal).ToArray()
: legacyEndpoints;
AuthorityRuntimeIds = AuthorityRoutes.Select(route => route.AuthorityRuntimeId)
.Distinct(StringComparer.Ordinal).OrderBy(value => value, StringComparer.Ordinal).ToArray();
ParentVerseId = parentVerseId;
Description = description;
}
@ -143,6 +216,8 @@ namespace GameCult.Mesh
/// Gets known authoritative runtime ids, when authority is cluster-shaped.
/// </summary>
public IReadOnlyList<string> AuthorityRuntimeIds { get; }
/// <summary>Gets exact authority-to-route bindings used for identity-sensitive resolution.</summary>
public IReadOnlyList<CultMeshAuthorityRoute> AuthorityRoutes { get; }
/// <summary>
/// Gets the parent Verse id for subscribed overlays or branches.
/// </summary>
@ -177,10 +252,29 @@ namespace GameCult.Mesh
private static string[] Clean(IEnumerable<string>? values)
{
return values?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray()
return values?.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.Ordinal)
.ToArray()
?? Array.Empty<string>();
}
private static CultMeshAuthorityRoute[] NormalizeRoutes(IEnumerable<CultMeshAuthorityRoute> routes)
{
return routes
.GroupBy(route => new
{
route.AuthorityRuntimeId,
route.Endpoint,
Protocols = string.Join("\u001f", route.ProtocolIds)
})
.Select(group => group.OrderBy(route => route.Priority).First())
.OrderBy(route => route.Priority)
.ThenBy(route => route.AuthorityRuntimeId, StringComparer.Ordinal)
.ThenBy(route => route.Endpoint, StringComparer.Ordinal)
.ToArray();
}
private static string RequireNonEmpty(string value, string paramName)
{
if (string.IsNullOrWhiteSpace(value))

View file

@ -33,6 +33,24 @@ namespace GameCult.Mesh
},
DiscoveryEndpoints = verse.DiscoveryEndpoints.ToArray(),
AuthorityRuntimeIds = verse.AuthorityRuntimeIds.ToArray(),
AuthorityRoutes = verse.AuthorityRoutes.Select(route => new CultMeshAuthorityRouteMessage
{
AuthorityRuntimeId = route.AuthorityRuntimeId,
Endpoint = route.Endpoint,
ProtocolIds = route.ProtocolIds.ToArray(),
Priority = route.Priority,
Generation = route.Generation,
Certificate = route.Certificate == null ? null : new CultMeshRouteCertificateMessage
{
ProviderKeyId = route.Certificate.ProviderKey.KeyId,
ProviderPublicKeyX = route.Certificate.ProviderKey.X,
ProviderPublicKeyY = route.Certificate.ProviderKey.Y,
OdinKeyId = route.Certificate.OdinKeyId,
IssuedAtUnixMilliseconds = route.Certificate.IssuedAtUnixMilliseconds,
ExpiresAtUnixMilliseconds = route.Certificate.ExpiresAtUnixMilliseconds,
Signature = route.Certificate.Signature
}
}).ToArray(),
ParentVerseId = verse.ParentVerseId,
Description = verse.Description
};
@ -50,6 +68,21 @@ namespace GameCult.Mesh
out var parsed)
? parsed
: CultMeshVerseAuthorityModel.SubscribedOverlay;
var routes = message.AuthorityRoutes?.Select(route => new CultMeshAuthorityRoute(
route.AuthorityRuntimeId,
route.Endpoint,
route.ProtocolIds,
route.Priority,
route.Generation,
route.Certificate == null ? null : new CultMeshRouteCertificate(
new CultMeshEcdsaP256PublicKey(
route.Certificate.ProviderKeyId,
route.Certificate.ProviderPublicKeyX,
route.Certificate.ProviderPublicKeyY),
route.Certificate.OdinKeyId,
route.Certificate.IssuedAtUnixMilliseconds,
route.Certificate.ExpiresAtUnixMilliseconds,
route.Certificate.Signature))).ToArray();
return new CultMeshVerseDescriptor(
message.VerseId,
message.DisplayName,
@ -60,10 +93,11 @@ namespace GameCult.Mesh
message.Compatibility.CompatibleVerseIds,
message.Compatibility.RequiredPluginIds,
message.Compatibility.OptionalPluginIds),
message.DiscoveryEndpoints,
message.AuthorityRuntimeIds,
routes is { Length: > 0 } ? null : message.DiscoveryEndpoints,
routes is { Length: > 0 } ? null : message.AuthorityRuntimeIds,
message.ParentVerseId,
message.Description);
message.Description,
routes is { Length: > 0 } ? routes : null);
}
/// <summary>Creates the canonical filtered Verse catalog response for any CultNet transport adapter.</summary>
@ -173,6 +207,12 @@ namespace GameCult.Mesh
/// </summary>
public Func<ICultNetSchemaClient>? CreateClient { get; set; }
/// <summary>
/// Gets or sets an endpoint-aware client factory for external transport packages.
/// When present, this owns discovery transport selection for each exact Odin route.
/// </summary>
public Func<string, ICultNetSchemaClient>? CreateClientForEndpoint { get; set; }
/// <summary>Gets or sets the clock that owns discovery deadlines.</summary>
public ICultMeshClock Clock { get; set; } = CultMeshSystemClock.Instance;
@ -307,7 +347,8 @@ namespace GameCult.Mesh
private ICultNetSchemaClient CreateClient(string endpoint)
{
return _options.CreateClient?.Invoke()
return _options.CreateClientForEndpoint?.Invoke(endpoint)
?? _options.CreateClient?.Invoke()
?? CultNetSchemaClients.CreateForEndpoint(endpoint, _options.Security, _options.ConfigureClient);
}

View file

@ -8,7 +8,8 @@ This is the implementation map for the browser Verse boundary.
`samples/eve-two-runtime` remains the clean package-consumer/jsdom checkpoint.
`samples/eve-browser-network` runs a durable C# provider, a real Chromium Eve
lowerer, an independent C# headless observer, and a separate local Odin fixture
over authenticated binary CultNet WebSocket routes. It proves one canonical
over binary loopback CultNet WebSocket routes under an explicit
`local-development` trust policy. It proves one canonical
command effect, idempotent retry, shared observation, provider restart on a new
physical route, browser rediscovery, retained lease resubscription, and a second
canonical command after reconnection.
@ -29,6 +30,10 @@ not a complete public Verse proof:
`cultmesh.verse_catalog_request.v0` and
`cultmesh.verse_catalog_response.v0`; application callbacks no longer need to
smuggle provider URLs into the browser client;
- `CultMeshBrowserClient` owns the final authority gate: remote use requires a
consumer-pinned Odin P-256 root, an Odin-signed route certificate, `wss://`,
and provider possession proof over a fresh nonce. A catalog source cannot
bypass that gate by returning a route-shaped object;
- Kotlin has a channel-aware WebSocket transport adapter, TypeScript has the
browser client, and `GameCult.Networking.WebSockets` provides the bounded C#
schema server host adapter;
@ -58,9 +63,9 @@ Owner:
Inputs:
- an Odin-discovered WebSocket transport candidate;
- an Odin-discovered route certificate and consumer-configured Odin root;
- stable Verse and provider identities;
- authenticated session evidence;
- provider key possession over a fresh client nonce;
- requested schema ids and record keys;
- typed operation invocations and idempotency keys;
- canonical CultNet schema-v0 MessagePack messages.

View file

@ -34,7 +34,7 @@ using var node = await CultMesh.CreateNodeAsync("counter.cc", new CultMeshNodeOp
CacheOptions = new CultCacheOpenOptions { Registry = registry },
DatabaseOptions = new CultNetDatabaseOptions
{
RuntimeId = "sample.counter-provider",
RuntimeId = "sample.counter-daemon",
DocumentRegistry = documents
}
});

View file

@ -1,24 +1,56 @@
# 2. Connect A Client By Stable Identity
Clients address a live session by stable Verse/provider identity. In this tutorial,
`sample.counter` is the Verse id and `sample.counter-provider` is the authority
`sample.counter` is the Verse id and `sample.counter-daemon` is the authority
runtime Odin currently advertises for it. Application clients pass that typed
pair, never a socket address and never one ambiguous string that might mean
either identity. A product may expose only a Verse choice to its user; its
selection owner then resolves the provider runtime from Odin's descriptor before
opening the session. Discovery owns the current physical routes, and the session
manager owns connection reuse, path rotation, and transport failure state.
Each Odin route binds that authority runtime to one endpoint, protocol set, and
generation. For a remote Verse, Odin signs that exact tuple and the provider's
P-256 public key. The client pins an Odin root from its own configuration, then
requires the connected provider to sign a fresh client nonce. A route cannot
become usable by merely repeating the expected Verse and runtime strings.
```csharp
using GameCult.Mesh;
using GameCult.Networking.WebSockets;
var odinEndpoint = Environment.GetEnvironmentVariable("ODIN_ENDPOINT")
?? throw new InvalidOperationException("Set ODIN_ENDPOINT to the configured rendezvous route.");
const string verseId = "sample.counter";
var target = new CultMeshSessionTarget(verseId, "sample.counter-provider");
var target = new CultMeshSessionTarget(verseId, "sample.counter-daemon");
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var cancellationToken = timeout.Token;
var odinRoot = new CultMeshEcdsaP256PublicKey(
Environment.GetEnvironmentVariable("ODIN_ROOT_KEY_ID")
?? throw new InvalidOperationException("Set ODIN_ROOT_KEY_ID."),
Environment.GetEnvironmentVariable("ODIN_ROOT_P256_X")
?? throw new InvalidOperationException("Set ODIN_ROOT_P256_X."),
Environment.GetEnvironmentVariable("ODIN_ROOT_P256_Y")
?? throw new InvalidOperationException("Set ODIN_ROOT_P256_Y."));
using var mesh = new CultMeshClient(new CultMeshClientOptions
{
RendezvousEndpoints = new[] { odinEndpoint }
RendezvousEndpoints = new[] { odinEndpoint },
Discovery = new CultMeshVerseDiscoveryClientOptions
{
CreateClientForEndpoint = _ => new CultNetWebSocketSchemaClient()
},
Sessions = new CultMeshSessionManagerOptions
{
Trust = new CultMeshAuthorityTrustPolicy(
CultMeshAuthorityTrustMode.AuthenticatedRemote,
new[] { odinRoot })
},
Connectors = new ICultMeshTransportConnector[]
{
new CultMeshUriSchemaTransportConnector(
"cultnet-websocket",
new[] { "wss" },
_ => new CultNetWebSocketSchemaClient())
}
});
using var counterLease = await mesh.LeaseDocumentAsync<CounterState>(
@ -58,6 +90,45 @@ using var stateWatch = session.WatchState().Subscribe(state =>
Application code does not reconnect, sleep, rank endpoints, or replace the
session. Those decisions belong to CultMesh.
For a deliberately local, moddable Verse, select the exception explicitly and
use only in-process or loopback routes:
```csharp
Sessions = new CultMeshSessionManagerOptions
{
Trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment)
}
```
`LocalDevelopment` never accepts an unsigned non-loopback route. Verse names,
provider names, and Odin wire fields cannot turn this exception on. Remote
routes require a trusted Odin signature, provider nonce proof, certificate
validity, and a protected channel.
The remote example requires both `GameCult.Mesh` and
`GameCult.Networking.WebSockets` at the same CultLib package version. Core
CultMesh does not silently install a network stack: the consumer chooses the
secure connector and its TLS policy. The default TCP connector exists for the
explicit loopback-development case and cannot satisfy authenticated-remote
trust.
An authenticated provider receives an Odin-certified route and its matching
provider private key from deployment configuration. It attaches the proof gate
to the same WSS schema server before publishing the route:
```csharp
using var identity = new CultMeshSessionIdentityServer(
schemaServer,
authorityRuntimeId: "sample.counter-daemon",
verseIds: new[] { "sample.counter" },
protocolIds: new[] { CultMeshProtocols.Documents.Value },
routeGenerations: new[] { certifiedRoute.Generation },
proofSigners: new[] { new CultMeshSessionProofSigner(certifiedRoute, providerPrivateKey) });
```
`certifiedRoute` is the exact signed route Odin publishes. The provider never
holds Odin's signing key, and an unsigned WSS route is still untrusted.
Fast session-management check (this uses controlled transports; it is not the
network proof):
@ -65,6 +136,11 @@ network proof):
dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj --filter FullyQualifiedName~CultMeshSessionManagerTests
```
The local checkpoint in chapter 4 additionally advertises a better-priority
endpoint for the wrong authority runtime. Both the C# and browser clients must
ignore it, verify the intended peer, survive route replacement, and reject
wrong-source operation responses.
Advanced hosts may inject lookup sources, connectors, clocks, persistence, and
diagnostics through the lower-level discovery and session APIs. Application
features should not need them.

View file

@ -97,6 +97,10 @@ GameCult dependency is `PackageReference Include="GameCult.Eve.Surface"`.
This proves the renderer-neutral provider contract does not require a sibling
Eve or CultLib source tree after packaging.
The real-process C# sample is then rebuilt from that same temporary NuGet feed.
The source project references in its `.csproj` are contributor convenience only
and are disabled by the checkpoint; they are not the consumer contract.
The first layer builds and packs `cultcache-ts`, `cultnet-ts`, `cultmesh-ts`,
`@gamecult/eve-contracts`, and `@gamecult/eve-browser-lowering`, installs the
tarballs into an empty temporary consumer, and runs
@ -108,11 +112,11 @@ both consumers converge on the same state and canonical receipt identity, a
duplicate idempotency key is not applied twice, and the state plus receipt
survive reopening the `.cc` store.
The second layer runs a durable C# provider, a separate local Odin fixture, a
real Chromium Eve lowerer, and an independent C# headless observer over binary
The second layer runs a durable C# provider, a wrong-authority decoy, a separate
local Odin fixture, a real Chromium Eve lowerer, and an independent C# headless observer over binary
CultNet WebSocket lanes. The open browser resolves the `sample.counter` Verse
through the canonical catalog, survives its provider restarting on a different route,
and resubscribes. The retained C# client then invokes a second typed operation
through the canonical catalog, rejects the decoy route, survives its provider
restarting on a different route, and resubscribes. The retained C# client then invokes a second typed operation
through `CultMeshClient.InvokeAsync`; Chromium and C# observe the same
provider-authored state and receipt chronology. The same retained C# session
then completes 10,000 typed no-op operations under explicit p99 and post-GC

View file

@ -10,6 +10,8 @@ The counter is now one small multiplayer game:
typed operation boundary;
- both clients remain attached when Odin moves the Verse to a replacement
physical provider route.
- a better-priority route bound to another runtime remains unreachable to both
clients throughout the chronology.
Run the whole chronology from the CultLib checkout:
@ -34,8 +36,10 @@ packages, installs those artifacts into an empty temporary consumer, and proves
DOM/headless convergence plus `.cc` reopen. This rejects a sample that only
works through repository-relative source imports.
The third checkpoint boots separate Odin, provider, Chromium, and C# headless
processes. Chromium performs the first increment. The provider then restarts on
The third checkpoint boots separate Odin, intended provider, wrong-authority
decoy provider, Chromium, and C# headless processes. Its C# application is built
from the temporary NuGet feed produced by the first checkpoint, not sibling
project references. Chromium performs the first increment. The provider then restarts on
a different route, Odin advertises the replacement, and the retained clients
resubscribe without application reconnect code. C# performs the second
increment. Both clients must observe count 2 and both canonical receipt ids.

View file

@ -4,14 +4,22 @@ This series builds one typed application state and presents it through multiple
Eve runtimes. It is maintained as an API acceptance test: examples use public
APIs, name the owner of each decision, and include a verification checkpoint.
Run the complete getting-started checkpoint:
Application consumers install released packages and do not need the source
repositories. This checkout also contains a maintainer checkpoint that creates
the same clean consumers from local package artifacts:
```powershell
pwsh -File ./scripts/verify-eve-getting-started.ps1
```
With clean sibling CultLib and Eve checkouts, the command installs missing build
dependencies and runs three explicit evidence layers. First it packs the
The maintainer command takes an explicit `-EveRoot` when Eve is not a sibling:
```powershell
pwsh -File ./scripts/verify-eve-getting-started.ps1 -EveRoot D:\src\Eve
```
It installs missing build dependencies and runs three explicit evidence layers.
First it packs the
CultLib managed dependency closure plus `GameCult.Eve.Surface`, then restores
and runs an empty .NET consumer using only `PackageReference`. Next it packs the five
TypeScript packages into another empty temporary consumer, validates the Eve surface
@ -20,7 +28,8 @@ headless observer converge, and reopens the durable `.cc` store. Then it boots a
durable C# provider, local Odin fixture, real Chromium lowerer, and independent
C# client. Chromium invokes before provider replacement; C# invokes afterward.
Both retained clients prove discovery by identity, canonical receipts, provider
restart on another route, resubscription, and durable convergence. The retained
restart on another route, rejection of a better-priority wrong-authority route,
resubscription, and durable convergence. The retained
C# session also completes 10,000 non-persistent typed operations under p99 and
post-GC managed-memory gates.
Set `CHROME_PATH` only when no installed Chrome, Chromium, Edge, or Playwright
@ -58,3 +67,6 @@ native projection. CultMesh owns discovery and connection continuity.
pull request.
- A setup step that depends on repository internals is an API defect, not
acceptable tutorial folklore.
- The maintainer command may read owner checkouts to create packages. Every
sample process it launches must consume those package artifacts rather than
compiling through sibling project references.

View file

@ -50,8 +50,12 @@ not the brand surface.
### Verses
`CultMeshVerseDescriptor` describes a rule-bearing consensus graph. It includes
transport compatibility, rules hash, authority model, discovery endpoints,
known authority runtimes, optional parent Verse id, and plugin requirements.
transport compatibility, rules hash, authority model, exact authority-route
bindings, optional parent Verse id, and plugin requirements. Each route binds
an authority runtime to one endpoint, protocol set, priority, and generation.
The old endpoint/runtime arrays are derived projections; a legacy descriptor
with several authority runtimes is rejected because it cannot identify which
runtime owns which route.
`CultMeshVerseCatalog` is the local reactive catalog for discovered Verses.
It can publish discovery updates and find compatible transfer targets.

View file

@ -9,7 +9,8 @@ be equally good at files, realtime state, and same-machine memory access.
registered connector for each traffic class. A connector owns only its physical
connection and byte movement.
**Inputs:** stable Verse/provider identity, advertised physical candidates,
**Inputs:** stable Verse/authority-runtime identity, exact Odin-bound physical
routes with protocol and generation,
connector support and priority, caller cancellation, and locality evidence for
body negotiation.
@ -63,13 +64,29 @@ operation.
transport diagnostics. None of these grant provider authority or become content
identity.
Before a schema, content, or realtime session becomes online, consumer-owned
trust verifies an Odin-signed route certificate binding Verse, authority
runtime, protocol set, endpoint, generation, validity interval, and provider
P-256 key. Schema and content lanes then require a fresh client nonce signed by
that provider key. QUIC uses the signed endpoint plus its TLS
endpoint/certificate proof. A connector that returns bytes without this chain
is rejected; racing every endpoint in a Verse descriptor is not route
selection. Unsigned traffic exists only under an explicitly selected
`LocalDevelopment` policy and only for in-process or loopback routes.
Route and session signatures use ECDSA P-256 with SHA-256 and fixed-width IEEE
P1363 `r || s` encoding, matching browser WebCrypto. Their canonical
transcripts are ordered UTF-8 fields prefixed by four-byte big-endian lengths;
serializer bytes are never signature authority.
**Forbidden writers:** applications and renderers do not choose unadvertised
endpoints, implement reconnect loops, or bypass verification. Schema messages
must not carry bulk bodies. RUDP/LiteNetLib cannot become an implicit default
for any traffic class. QUIC does not own schemas or immutable files merely
because it can transport bytes.
**Shared paths:** discovery and identity resolution remain common. Each traffic
**Shared paths:** exact route resolution and peer verification remain common.
Each traffic
class has its own connector set, connection cache, priority tiers, and failure
state. All content connectors feed the same transfer and promotion owner.
@ -84,7 +101,8 @@ new seam does not pretend that migration has already occurred.
## Security and release gate
The current TCP control and content connectors are plaintext and therefore
restricted to local or otherwise trusted deployment. Adding TLS must not move
restricted to explicit loopback development. They cannot carry an authenticated
remote session. Adding TLS must not move
discovery, authorization, content verification, or application semantics into
the TCP adapter.

View file

@ -21,8 +21,10 @@ The same transport can carry many Verses:
subscribed overlay
- `Compatibility`: transport version, rules hash, compatible Verse ids, and
plugin requirements
- `DiscoveryEndpoints`: endpoints where nodes for the Verse can be found
- `AuthorityRuntimeIds`: known authoritative runtimes when cluster-shaped
- `AuthorityRoutes`: exact authority-runtime, endpoint, protocol, priority, and
generation bindings. This is the routing authority.
- `DiscoveryEndpoints`: read-only endpoint projection for legacy inspection
- `AuthorityRuntimeIds`: read-only authority projection for legacy inspection
- `ParentVerseId`: source Verse for overlays or branches
## Compatibility
@ -57,6 +59,13 @@ static config, cloud discovery, peer gossip, or mod portals. It does not care
where the descriptor came from. That keeps discovery pluggable while the Verse
model stays stable.
An endpoint and an authority id are not two independent facts. A descriptor
with explicit routes binds them. The legacy parallel arrays are accepted only
when exactly one authority runtime exists; a multi-authority legacy descriptor
is ambiguous and fails closed. Session establishment then requires the selected
physical route to prove the exact Verse, authority runtime, protocol, and route
generation before the session becomes online.
Peer exchange is the torrent-shaped layer under Verse discovery. A Verse
catalog says which graph exists; a peer catalog says which nodes can currently
serve or observe parts of that graph. Peer cards can advertise discovery,

View file

@ -0,0 +1,46 @@
using System;
using System.IO;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
namespace GameCult.Networking.WebSockets;
internal static class CultNetWebSocketMessageIO
{
public static Task SendAsync(WebSocket socket, byte[] payload, CancellationToken cancellationToken) =>
socket.SendAsync(new ArraySegment<byte>(payload), WebSocketMessageType.Binary, true, cancellationToken);
public static async Task<byte[]?> ReceiveAsync(
WebSocket socket,
int maxMessageBytes,
CancellationToken cancellationToken)
{
var segment = new byte[Math.Min(16 * 1024, maxMessageBytes)];
using var message = new MemoryStream();
while (true)
{
var result = await socket.ReceiveAsync(new ArraySegment<byte>(segment), cancellationToken)
.ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close) return null;
if (result.MessageType != WebSocketMessageType.Binary)
{
await socket.CloseAsync(
WebSocketCloseStatus.InvalidMessageType,
"CultNet requires binary MessagePack WebSocket messages.",
cancellationToken).ConfigureAwait(false);
return null;
}
if (message.Length + result.Count > maxMessageBytes)
{
await socket.CloseAsync(
WebSocketCloseStatus.MessageTooBig,
"CultNet message exceeds the configured maximum.",
cancellationToken).ConfigureAwait(false);
return null;
}
message.Write(segment, 0, result.Count);
if (result.EndOfMessage) return message.ToArray();
}
}
}

View file

@ -37,7 +37,7 @@ public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICul
/// <inheritdoc />
public void Connect(string host, int port)
{
ArgumentException.ThrowIfNullOrWhiteSpace(host);
if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("Host is required.", nameof(host));
if (port <= 0 || port > 65535) throw new ArgumentOutOfRangeException(nameof(port));
ConnectAsync(new Uri($"ws://{host}:{port}/cultmesh"), CancellationToken.None)
.GetAwaiter().GetResult();
@ -46,7 +46,7 @@ public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICul
/// <summary>Connects to an exact WebSocket route.</summary>
public async Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(endpoint);
if (endpoint == null) throw new ArgumentNullException(nameof(endpoint));
if (endpoint.Scheme is not ("ws" or "wss"))
throw new ArgumentException("CultNet WebSocket endpoint must use ws:// or wss://.", nameof(endpoint));
ThrowIfDisposed();
@ -69,7 +69,7 @@ public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICul
/// <inheritdoc />
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage
{
ArgumentNullException.ThrowIfNull(message);
if (message == null) throw new ArgumentNullException(nameof(message));
ThrowIfDisposed();
var socket = _socket;
if (socket?.State != WebSocketState.Open)
@ -92,7 +92,7 @@ public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICul
/// <inheritdoc />
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
{
ArgumentNullException.ThrowIfNull(callback);
if (callback == null) throw new ArgumentNullException(nameof(callback));
ThrowIfDisposed();
lock (_handlerGate)
{
@ -161,6 +161,6 @@ public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICul
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_disposed) throw new ObjectDisposedException(nameof(CultNetWebSocketSchemaClient));
}
}

View file

@ -178,44 +178,6 @@ public sealed class CultNetWebSocketSchemaServer :
}
}
internal static class CultNetWebSocketMessageIO
{
public static Task SendAsync(WebSocket socket, byte[] payload, CancellationToken cancellationToken) =>
socket.SendAsync(payload, WebSocketMessageType.Binary, true, cancellationToken);
public static async Task<byte[]?> ReceiveAsync(
WebSocket socket,
int maxMessageBytes,
CancellationToken cancellationToken)
{
var segment = new byte[Math.Min(16 * 1024, maxMessageBytes)];
using var message = new MemoryStream();
while (true)
{
var result = await socket.ReceiveAsync(segment, cancellationToken).ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close) return null;
if (result.MessageType != WebSocketMessageType.Binary)
{
await socket.CloseAsync(
WebSocketCloseStatus.InvalidMessageType,
"CultNet requires binary MessagePack WebSocket messages.",
cancellationToken).ConfigureAwait(false);
return null;
}
if (message.Length + result.Count > maxMessageBytes)
{
await socket.CloseAsync(
WebSocketCloseStatus.MessageTooBig,
"CultNet message exceeds the configured maximum.",
cancellationToken).ConfigureAwait(false);
return null;
}
message.Write(segment, 0, result.Count);
if (result.EndOfMessage) return message.ToArray();
}
}
}
/// <summary>ASP.NET Core endpoint mapping for the CultNet WebSocket schema server.</summary>
public static class CultNetWebSocketEndpointRouteBuilderExtensions
{

View file

@ -1,12 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework></TargetFramework>
<TargetFrameworks>net10.0;netstandard2.1</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Description>CultNet schema-v0 WebSocket host adapter for browser and web runtimes</Description>
<PackageId>GameCult.Networking.WebSockets</PackageId>
<PackageTags>game;networking;websocket;cultnet;cultmesh</PackageTags>
</PropertyGroup>
<ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<ProjectReference Include="..\GameCult.Networking\GameCult.Networking.csproj" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.1'">
<ProjectReference Include="..\GameCult.Networking\GameCult.Networking.csproj" />
<Compile Remove="CultNetWebSocketSchemaServer.cs" />
</ItemGroup>
</Project>

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
using R3;
@ -297,6 +298,11 @@ namespace GameCult.Networking
/// Gets or sets the durable store for accepted shard mutation logs.
/// </summary>
public ICultNetShardMutationLogStore? MutationLogStore { get; set; }
/// <summary>
/// Gets or sets whether authoritative writes must occur inside ExecuteTransactionAsync.
/// Use this for state owners that forbid record-at-a-time mutation bypasses.
/// </summary>
public bool RequireTransactionsForAuthoritativeWrites { get; set; }
}
/// <summary>
@ -464,6 +470,8 @@ namespace GameCult.Networking
private readonly Dictionary<string, long> _nextLogSequences = new(StringComparer.Ordinal);
private readonly Dictionary<string, long> _appliedShardSequences = new(StringComparer.Ordinal);
private readonly Subject<object> _changes = new();
private readonly AsyncLocal<CultNetDatabaseTransaction?> _ambientTransaction = new();
private readonly bool _requireTransactionsForAuthoritativeWrites;
private bool _disposed;
/// <summary>
@ -476,6 +484,7 @@ namespace GameCult.Networking
_runtimeId = options.RuntimeId;
_documents = options.DocumentRegistry ?? new CultNetDocumentRegistry(cache.Registry);
_mutationLogStore = options.MutationLogStore;
_requireTransactionsForAuthoritativeWrites = options.RequireTransactionsForAuthoritativeWrites;
_shards = (options.Shards == null || options.Shards.Count == 0
? new[] { CultNetShardDescriptor.PrimaryAll(options.RuntimeId) }
: options.Shards)
@ -503,6 +512,49 @@ namespace GameCult.Networking
/// </summary>
public IReadOnlyList<CultNetClientAuthorityScope> ClientAuthorityScopes => _clientAuthorityScopes;
/// <summary>
/// Runs one buffered authoritative database mutation. Cache state, durable state,
/// mutation logs, and database observers advance only after the complete stage succeeds.
/// </summary>
public async Task ExecuteTransactionAsync(Func<Task> stageAsync, bool soft = false)
{
ThrowIfDisposed();
if (stageAsync == null) throw new ArgumentNullException(nameof(stageAsync));
if (_ambientTransaction.Value != null)
{
await stageAsync().ConfigureAwait(false);
return;
}
var transaction = new CultNetDatabaseTransaction();
IReadOnlyList<Action> afterCommit;
_ambientTransaction.Value = transaction;
try
{
await _cache.ExecuteTransactionAsync(stageAsync, soft).ConfigureAwait(false);
afterCommit = transaction.AfterCommit.ToArray();
}
finally
{
_ambientTransaction.Value = null;
}
foreach (var publish in afterCommit)
publish();
}
/// <summary>Runs one value-producing buffered authoritative database mutation.</summary>
public async Task<T> ExecuteTransactionAsync<T>(Func<Task<T>> stageAsync, bool soft = false)
{
if (stageAsync == null) throw new ArgumentNullException(nameof(stageAsync));
T result = default!;
await ExecuteTransactionAsync(async () =>
{
result = await stageAsync().ConfigureAwait(false);
}, soft).ConfigureAwait(false);
return result;
}
/// <summary>Gets whether this database instance can authoritatively replace the document at a key.</summary>
public bool CanWriteAuthoritatively<T>(CultRecordKey key) where T : class
{
@ -817,6 +869,7 @@ namespace GameCult.Networking
public async Task<CultRecordHandle<T>> PutAsync<T>(CultRecordKey key, T document) where T : class
{
ThrowIfDisposed();
EnsureAuthoritativeTransaction();
if (document == null) throw new ArgumentNullException(nameof(document));
var descriptor = _cache.Registry.GetRequired<T>();
@ -825,20 +878,27 @@ namespace GameCult.Networking
var previous = _cache.Get<T>(key);
var handle = await _cache.UpsertAsync(document, new CultRecordHandle<T>(key)).ConfigureAwait(false);
AppendMutationLogEntry(
shard,
previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated,
key,
descriptor.SchemaId,
document,
previous);
Publish(new CultNetDatabaseChange<T>(
previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated,
key,
descriptor.SchemaId,
shard,
document,
previous));
void PublishCommittedPut()
{
AppendMutationLogEntry(
shard,
previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated,
key,
descriptor.SchemaId,
document,
previous);
Publish(new CultNetDatabaseChange<T>(
previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated,
key,
descriptor.SchemaId,
shard,
document,
previous));
}
if (_ambientTransaction.Value is { } transaction)
transaction.AfterCommit.Add(PublishCommittedPut);
else
PublishCommittedPut();
return handle;
}
@ -873,6 +933,7 @@ namespace GameCult.Networking
public Task DeleteAsync<T>(CultRecordKey key) where T : class
{
ThrowIfDisposed();
EnsureAuthoritativeTransaction();
var descriptor = _cache.Registry.GetRequired<T>();
var shard = ResolveShardInternal(descriptor, key);
EnsurePrimary(shard, descriptor.SchemaId, key);
@ -881,20 +942,27 @@ namespace GameCult.Networking
if (previous != null)
{
_cache.Remove(new CultRecordHandle<T>(key));
AppendMutationLogEntry(
shard,
CultNetDatabaseChangeKind.Removed,
key,
descriptor.SchemaId,
document: null,
previousDocument: previous);
Publish(new CultNetDatabaseChange<T>(
CultNetDatabaseChangeKind.Removed,
key,
descriptor.SchemaId,
shard,
document: null,
previousDocument: previous));
void PublishCommittedDelete()
{
AppendMutationLogEntry(
shard,
CultNetDatabaseChangeKind.Removed,
key,
descriptor.SchemaId,
document: null,
previousDocument: previous);
Publish(new CultNetDatabaseChange<T>(
CultNetDatabaseChangeKind.Removed,
key,
descriptor.SchemaId,
shard,
document: null,
previousDocument: previous));
}
if (_ambientTransaction.Value is { } transaction)
transaction.AfterCommit.Add(PublishCommittedDelete);
else
PublishCommittedDelete();
}
return Task.CompletedTask;
@ -906,6 +974,7 @@ namespace GameCult.Networking
public async Task<object> ApplyPutAsync(CultNetDocumentPutRawMessage message)
{
ThrowIfDisposed();
EnsureAuthoritativeTransaction();
if (message == null) throw new ArgumentNullException(nameof(message));
if (message.Document == null)
{
@ -920,31 +989,41 @@ namespace GameCult.Networking
var document = await _documents.ApplyRawDocumentPutMessageAsync(_cache, message).ConfigureAwait(false);
var kind = previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated;
var predictionKey = PredictionKey(descriptor.SchemaId, key);
if (_predictedDocuments.Remove(predictionKey))
var reconcilesPrediction = _predictedDocuments.ContainsKey(predictionKey);
if (reconcilesPrediction)
{
kind = CultNetDatabaseChangeKind.Reconciled;
}
AppendMutationLogEntry(
shard,
kind == CultNetDatabaseChangeKind.Reconciled ? CultNetDatabaseChangeKind.Updated : kind,
key,
descriptor.SchemaId,
document,
previous,
new CultNetShardLogEntryMessage
{
ChangeKind = previous == null ? "added" : "updated",
Put = message
});
PublishUntyped(
descriptor.DocumentType,
kind,
key,
descriptor.SchemaId,
shard,
document,
previous);
void PublishCommittedPut()
{
if (reconcilesPrediction)
_predictedDocuments.Remove(predictionKey);
AppendMutationLogEntry(
shard,
kind == CultNetDatabaseChangeKind.Reconciled ? CultNetDatabaseChangeKind.Updated : kind,
key,
descriptor.SchemaId,
document,
previous,
new CultNetShardLogEntryMessage
{
ChangeKind = previous == null ? "added" : "updated",
Put = message
});
PublishUntyped(
descriptor.DocumentType,
kind,
key,
descriptor.SchemaId,
shard,
document,
previous);
}
if (_ambientTransaction.Value is { } transaction)
transaction.AfterCommit.Add(PublishCommittedPut);
else
PublishCommittedPut();
return document;
}
@ -954,6 +1033,7 @@ namespace GameCult.Networking
public Task ApplyDeleteAsync(CultNetDocumentDeleteMessage message)
{
ThrowIfDisposed();
EnsureAuthoritativeTransaction();
if (message == null) throw new ArgumentNullException(nameof(message));
var key = new CultRecordKey(message.RecordKey);
@ -967,26 +1047,33 @@ namespace GameCult.Networking
}
_cache.Remove(key);
AppendMutationLogEntry(
shard,
CultNetDatabaseChangeKind.Removed,
key,
descriptor.SchemaId,
document: null,
previousDocument: previous,
wireEntry: new CultNetShardLogEntryMessage
{
ChangeKind = "removed",
Delete = message
});
PublishUntyped(
descriptor.DocumentType,
CultNetDatabaseChangeKind.Removed,
key,
descriptor.SchemaId,
shard,
document: null,
previousDocument: previous);
void PublishCommittedDelete()
{
AppendMutationLogEntry(
shard,
CultNetDatabaseChangeKind.Removed,
key,
descriptor.SchemaId,
document: null,
previousDocument: previous,
wireEntry: new CultNetShardLogEntryMessage
{
ChangeKind = "removed",
Delete = message
});
PublishUntyped(
descriptor.DocumentType,
CultNetDatabaseChangeKind.Removed,
key,
descriptor.SchemaId,
shard,
document: null,
previousDocument: previous);
}
if (_ambientTransaction.Value is { } transaction)
transaction.AfterCommit.Add(PublishCommittedDelete);
else
PublishCommittedDelete();
return Task.CompletedTask;
}
@ -1699,6 +1786,20 @@ namespace GameCult.Networking
};
}
private sealed class CultNetDatabaseTransaction
{
public List<Action> AfterCommit { get; } = new();
}
private void EnsureAuthoritativeTransaction()
{
if (_requireTransactionsForAuthoritativeWrites && _ambientTransaction.Value == null)
{
throw new InvalidOperationException(
"This CultNet database requires authoritative writes to use ExecuteTransactionAsync.");
}
}
private void ThrowIfDisposed()
{
if (_disposed)

View file

@ -13,6 +13,8 @@ namespace GameCult.Networking
{
/// <summary>
/// Publishes live database changes through any schema-v0 server transport.
/// Optional record projection runs after authorization and owns both initial
/// snapshot and live-update delivery for a peer.
/// </summary>
public sealed class CultNetDatabaseSubscriptionServer : IDisposable
{
@ -20,18 +22,32 @@ namespace GameCult.Networking
private readonly CultNetDatabase _database;
private readonly Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, Task> _subscribe;
private readonly Func<CultNetDatabaseUnsubscribeMessage, ICultNetSchemaServerPeer, Task> _unsubscribe;
private readonly Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, bool>? _authorizeRequest;
private readonly Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, string, string, bool>? _authorizeRecord;
private readonly Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, CultNetRawDocumentRecord, CultNetRawDocumentRecord?>? _projectRecord;
private readonly ICultNetSchemaServerPeerLifecycle? _peerLifecycle;
private readonly ConcurrentDictionary<SubscriptionKey, IDisposable> _subscriptions =
new ConcurrentDictionary<SubscriptionKey, IDisposable>(SubscriptionKeyComparer.Instance);
private readonly ConcurrentDictionary<SubscriptionKey, CultNetDatabaseSubscribeMessage> _requests =
new ConcurrentDictionary<SubscriptionKey, CultNetDatabaseSubscribeMessage>(SubscriptionKeyComparer.Instance);
private readonly ConcurrentDictionary<SubscriptionKey, SubscriptionProjectionState> _projections =
new ConcurrentDictionary<SubscriptionKey, SubscriptionProjectionState>(SubscriptionKeyComparer.Instance);
private readonly object _lifecycleGate = new();
private bool _disposed;
/// <summary>Attaches live database subscription handlers to a schema server.</summary>
public CultNetDatabaseSubscriptionServer(ICultNetSchemaServer server, CultNetDatabase database)
public CultNetDatabaseSubscriptionServer(
ICultNetSchemaServer server,
CultNetDatabase database,
Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, bool>? authorizeRequest = null,
Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, string, string, bool>? authorizeRecord = null,
Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, CultNetRawDocumentRecord, CultNetRawDocumentRecord?>? projectRecord = null)
{
_server = server ?? throw new ArgumentNullException(nameof(server));
_database = database ?? throw new ArgumentNullException(nameof(database));
_authorizeRequest = authorizeRequest;
_authorizeRecord = authorizeRecord;
_projectRecord = projectRecord;
_subscribe = HandleSubscribeAsync;
_unsubscribe = HandleUnsubscribeAsync;
_server.OnCultNet(_subscribe);
@ -48,34 +64,48 @@ namespace GameCult.Networking
/// </summary>
public event Action<CultNetDatabaseSubscriptionDemand>? DemandChanged;
/// <summary>
/// Re-evaluates every live peer projection against current authorization and source state.
/// Records that lost visibility are explicitly removed and body demand is withdrawn while
/// the request is unauthorized. The subscription intent remains available for a later
/// authorized generation on the same established peer.
/// </summary>
public void Reconcile()
{
lock (_lifecycleGate)
{
if (_disposed) return;
foreach (var entry in _requests.ToArray())
Reconcile(entry.Key, entry.Value);
}
}
/// <summary>Detaches handlers and releases all active watches.</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_server.RemoveCultNetMessageListener<CultNetDatabaseSubscribeMessage>(_subscribe);
_server.RemoveCultNetMessageListener<CultNetDatabaseUnsubscribeMessage>(_unsubscribe);
if (_peerLifecycle != null)
_peerLifecycle.PeerDisconnected -= HandlePeerDisconnected;
foreach (var entry in _subscriptions)
lock (_lifecycleGate)
{
entry.Value.Dispose();
if (_requests.TryRemove(entry.Key, out var request))
PublishDemand(request, entry.Key, active: false);
if (_disposed) return;
_disposed = true;
_server.RemoveCultNetMessageListener<CultNetDatabaseSubscribeMessage>(_subscribe);
_server.RemoveCultNetMessageListener<CultNetDatabaseUnsubscribeMessage>(_unsubscribe);
if (_peerLifecycle != null)
_peerLifecycle.PeerDisconnected -= HandlePeerDisconnected;
foreach (var entry in _subscriptions.ToArray())
Withdraw(entry.Key, sendRemovals: false, forgetRequest: true);
_subscriptions.Clear();
_requests.Clear();
_projections.Clear();
}
_subscriptions.Clear();
_requests.Clear();
}
private void HandlePeerDisconnected(ICultNetSchemaServerPeer peer)
{
foreach (var key in _subscriptions.Keys.Where(candidate =>
SubscriptionKeyComparer.SamePeer(candidate.Peer, peer)).ToArray())
lock (_lifecycleGate)
{
if (_subscriptions.TryRemove(key, out var subscription))
subscription.Dispose();
if (_requests.TryRemove(key, out var request))
PublishDemand(request, key, active: false);
foreach (var key in _subscriptions.Keys.Where(candidate =>
SubscriptionKeyComparer.SamePeer(candidate.Peer, peer)).ToArray())
Withdraw(key, sendRemovals: false, forgetRequest: true);
}
}
@ -89,39 +119,49 @@ namespace GameCult.Networking
peer.SendCultNet(new CultNetErrorMessage { Error = "Database subscription requires a subscriptionId or messageId." });
return Task.CompletedTask;
}
var key = new SubscriptionKey(peer, subscriptionId);
if (_requests.TryGetValue(key, out var previous))
PublishDemand(previous, key, active: false);
_subscriptions.AddOrUpdate(
key,
_ => Watch(request, subscriptionId, peer),
(_, current) =>
{
current.Dispose();
return Watch(request, subscriptionId, peer);
});
_requests[key] = request;
PublishDemand(request, key, active: true);
if (request.IncludeSnapshot)
if (_authorizeRequest?.Invoke(request, peer) == false)
{
peer.SendCultNet(_database.Documents.CreateRawSnapshotResponse(
_database.Cache,
request.MessageId,
new CultNetSnapshotRequestMessage
{
MessageId = request.MessageId,
SchemaIds = request.SchemaIds,
RecordKeys = request.RecordKeys
}));
peer.SendCultNet(new CultNetErrorMessage { Error = "Database subscription is not authorized for this peer." });
return Task.CompletedTask;
}
else
lock (_lifecycleGate)
{
peer.SendCultNet(new CultNetSnapshotResponseRawMessage
var key = new SubscriptionKey(peer, subscriptionId);
Withdraw(key, sendRemovals: true, forgetRequest: true);
var projection = new SubscriptionProjectionState();
_projections[key] = projection;
_requests[key] = request;
try
{
MessageId = request.MessageId,
Documents = Array.Empty<CultNetRawDocumentRecord>()
});
_subscriptions[key] = Watch(request, subscriptionId, peer, key);
if (request.IncludeSnapshot)
{
var snapshot = CreateProjectedSnapshot(request, peer);
foreach (var entry in snapshot.BySourceRecordKey)
projection.DeliveredBySourceRecordKey[entry.Key] = entry.Value;
peer.SendCultNet(new CultNetSnapshotResponseRawMessage
{
MessageId = request.MessageId,
Documents = snapshot.Documents
});
}
else
{
peer.SendCultNet(new CultNetSnapshotResponseRawMessage
{
MessageId = request.MessageId,
Documents = Array.Empty<CultNetRawDocumentRecord>()
});
}
PublishDemand(request, key, active: true);
projection.DemandActive = true;
}
catch
{
Withdraw(key, sendRemovals: false, forgetRequest: true);
throw;
}
}
return Task.CompletedTask;
}
@ -134,9 +174,8 @@ namespace GameCult.Networking
if (!string.IsNullOrWhiteSpace(subscriptionId))
{
var key = new SubscriptionKey(peer, subscriptionId);
if (_subscriptions.TryRemove(key, out var subscription)) subscription.Dispose();
if (_requests.TryRemove(key, out var subscribed))
PublishDemand(subscribed, key, active: false);
lock (_lifecycleGate)
Withdraw(key, sendRemovals: false, forgetRequest: true);
}
return Task.CompletedTask;
}
@ -179,19 +218,232 @@ namespace GameCult.Networking
private IDisposable Watch(
CultNetDatabaseSubscribeMessage request,
string subscriptionId,
ICultNetSchemaServerPeer peer)
ICultNetSchemaServerPeer peer,
SubscriptionKey key)
{
return _database.WatchAllChanges().Subscribe(change =>
{
var message = CreateChange(change, request, subscriptionId);
if (message != null) peer.SendCultNet(message);
lock (_lifecycleGate)
{
if (_disposed || !_requests.ContainsKey(key) || !_projections.TryGetValue(key, out var projection))
return;
if (_authorizeRequest?.Invoke(request, peer) == false)
{
Reconcile(key, request);
return;
}
var sourceRecordKey = ResolveChangeRecordKey(change);
var message = CreateAuthorizedChange(change, request, subscriptionId, peer);
var projected = message?.Document;
if (projected != null && _projectRecord != null)
projected = _projectRecord(request, peer, projected);
ApplyProjectedChange(
projection,
sourceRecordKey,
projected,
peer,
subscriptionId);
}
});
}
private void Reconcile(SubscriptionKey key, CultNetDatabaseSubscribeMessage request)
{
if (!_projections.TryGetValue(key, out var projection)) return;
var authorized = _authorizeRequest?.Invoke(request, key.Peer) != false;
var next = authorized
? CreateProjectedSnapshot(request, key.Peer)
: ProjectedSnapshot.Empty;
foreach (var previous in projection.DeliveredBySourceRecordKey.ToArray())
{
if (!next.BySourceRecordKey.TryGetValue(previous.Key, out var current))
{
SendRemoval(key.Peer, key.Id, previous.Value);
continue;
}
if (!string.Equals(previous.Value.RecordKey, current.RecordKey, StringComparison.Ordinal) ||
!string.Equals(previous.Value.SchemaId, current.SchemaId, StringComparison.Ordinal))
{
SendRemoval(key.Peer, key.Id, previous.Value);
SendUpsert(key.Peer, key.Id, current, added: true);
}
else if (!Equivalent(previous.Value, current))
{
SendUpsert(key.Peer, key.Id, current, added: false);
}
}
foreach (var current in next.BySourceRecordKey)
{
if (!projection.DeliveredBySourceRecordKey.ContainsKey(current.Key))
SendUpsert(key.Peer, key.Id, current.Value, added: true);
}
projection.DeliveredBySourceRecordKey.Clear();
foreach (var current in next.BySourceRecordKey)
projection.DeliveredBySourceRecordKey[current.Key] = current.Value;
if (projection.DemandActive != authorized)
{
projection.DemandActive = authorized;
PublishDemand(request, key, authorized);
}
}
private ProjectedSnapshot CreateProjectedSnapshot(
CultNetDatabaseSubscribeMessage request,
ICultNetSchemaServerPeer peer)
{
var snapshot = _database.Documents.CreateRawSnapshotResponse(
_database.Cache,
request.MessageId,
new CultNetSnapshotRequestMessage
{
MessageId = request.MessageId,
SchemaIds = request.SchemaIds,
RecordKeys = request.RecordKeys
});
var bySourceRecordKey = new Dictionary<string, CultNetRawDocumentRecord>(StringComparer.Ordinal);
var projectedRecordKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var source in snapshot.Documents)
{
var sourceRecordKey = source.RecordKey;
if (_authorizeRecord?.Invoke(request, peer, sourceRecordKey, source.SchemaId) == false)
continue;
var projected = _projectRecord == null ? source : _projectRecord(request, peer, source);
if (projected == null) continue;
if (!projectedRecordKeys.Add(projected.RecordKey))
throw new InvalidOperationException(
$"Database subscription projection produced duplicate record key '{projected.RecordKey}'.");
bySourceRecordKey[sourceRecordKey] = projected;
}
return new ProjectedSnapshot(bySourceRecordKey);
}
private void ApplyProjectedChange(
SubscriptionProjectionState projection,
string sourceRecordKey,
CultNetRawDocumentRecord? current,
ICultNetSchemaServerPeer peer,
string subscriptionId)
{
projection.DeliveredBySourceRecordKey.TryGetValue(sourceRecordKey, out var previous);
if (current == null)
{
if (previous != null)
{
projection.DeliveredBySourceRecordKey.Remove(sourceRecordKey);
SendRemoval(peer, subscriptionId, previous);
}
return;
}
if (previous == null)
{
projection.DeliveredBySourceRecordKey[sourceRecordKey] = current;
SendUpsert(peer, subscriptionId, current, added: true);
return;
}
if (!string.Equals(previous.RecordKey, current.RecordKey, StringComparison.Ordinal) ||
!string.Equals(previous.SchemaId, current.SchemaId, StringComparison.Ordinal))
{
SendRemoval(peer, subscriptionId, previous);
projection.DeliveredBySourceRecordKey[sourceRecordKey] = current;
SendUpsert(peer, subscriptionId, current, added: true);
return;
}
if (!Equivalent(previous, current))
{
projection.DeliveredBySourceRecordKey[sourceRecordKey] = current;
SendUpsert(peer, subscriptionId, current, added: false);
}
}
private static string ResolveChangeRecordKey(object change)
{
var value = change.GetType().GetProperty("Key")?.GetValue(change);
return value is CultRecordKey key ? key.Value : string.Empty;
}
private static bool Equivalent(CultNetRawDocumentRecord left, CultNetRawDocumentRecord right) =>
string.Equals(left.SchemaId, right.SchemaId, StringComparison.Ordinal) &&
string.Equals(left.SchemaName, right.SchemaName, StringComparison.Ordinal) &&
string.Equals(left.SchemaVersion, right.SchemaVersion, StringComparison.Ordinal) &&
string.Equals(left.SchemaContentHash, right.SchemaContentHash, StringComparison.Ordinal) &&
string.Equals(left.RecordKey, right.RecordKey, StringComparison.Ordinal) &&
string.Equals(left.PayloadEncoding, right.PayloadEncoding, StringComparison.Ordinal) &&
left.Payload.SequenceEqual(right.Payload) &&
string.Equals(left.SourceRuntimeId, right.SourceRuntimeId, StringComparison.Ordinal) &&
string.Equals(left.SourceAgentId, right.SourceAgentId, StringComparison.Ordinal) &&
string.Equals(left.SourceRole, right.SourceRole, StringComparison.Ordinal);
private static void SendUpsert(
ICultNetSchemaServerPeer peer,
string subscriptionId,
CultNetRawDocumentRecord document,
bool added) =>
peer.SendCultNet(new CultNetDatabaseChangeRawMessage
{
MessageId = Guid.NewGuid().ToString("N"),
SubscriptionId = subscriptionId,
ChangeKind = added ? "added" : "updated",
Document = document
});
private static void SendRemoval(
ICultNetSchemaServerPeer peer,
string subscriptionId,
CultNetRawDocumentRecord document) =>
peer.SendCultNet(new CultNetDatabaseChangeRawMessage
{
MessageId = Guid.NewGuid().ToString("N"),
SubscriptionId = subscriptionId,
ChangeKind = "removed",
RecordKey = document.RecordKey,
SchemaId = document.SchemaId
});
private void Withdraw(SubscriptionKey key, bool sendRemovals, bool forgetRequest)
{
if (_subscriptions.TryRemove(key, out var subscription))
subscription.Dispose();
if (_projections.TryRemove(key, out var projection))
{
if (sendRemovals)
{
foreach (var delivered in projection.DeliveredBySourceRecordKey.Values)
SendRemoval(key.Peer, key.Id, delivered);
}
if (projection.DemandActive && _requests.TryGetValue(key, out var activeRequest))
PublishDemand(activeRequest, key, active: false);
}
if (forgetRequest)
_requests.TryRemove(key, out _);
}
private CultNetDatabaseChangeRawMessage? CreateAuthorizedChange(
object change,
CultNetDatabaseSubscribeMessage request,
string subscriptionId,
ICultNetSchemaServerPeer peer)
{
return CreateChangeCore(change, request, subscriptionId, (recordKey, schemaId) =>
_authorizeRecord?.Invoke(request, peer, recordKey, schemaId) != false);
}
private CultNetDatabaseChangeRawMessage? CreateChange(
object change,
CultNetDatabaseSubscribeMessage request,
string subscriptionId)
{
return CreateChangeCore(change, request, subscriptionId, (_, _) => true);
}
private CultNetDatabaseChangeRawMessage? CreateChangeCore(
object change,
CultNetDatabaseSubscribeMessage request,
string subscriptionId,
Func<string, string, bool> authorizeRecord)
{
var changeType = change.GetType();
var key = (CultRecordKey)(changeType.GetProperty("Key")?.GetValue(change) ?? new CultRecordKey(""));
@ -206,6 +458,8 @@ namespace GameCult.Networking
var schemaId = ResolveWireSchemaId(previous, (string?)changeType.GetProperty("SchemaId")?.GetValue(change) ?? "");
if (!MatchesRequestedSchema(request.SchemaIds, previous, schemaId))
return null;
if (!authorizeRecord(key.Value, schemaId))
return null;
return new CultNetDatabaseChangeRawMessage
{
MessageId = Guid.NewGuid().ToString("N"),
@ -219,6 +473,8 @@ namespace GameCult.Networking
var raw = CreateRawRecord(key, document);
if (!MatchesRequestedSchema(request.SchemaIds, document, raw.SchemaId))
return null;
if (!authorizeRecord(key.Value, raw.SchemaId))
return null;
return new CultNetDatabaseChangeRawMessage
{
MessageId = Guid.NewGuid().ToString("N"),
@ -258,6 +514,27 @@ namespace GameCult.Networking
return ((CultNetDocumentPutRawMessage)put!).Document;
}
private sealed class SubscriptionProjectionState
{
public Dictionary<string, CultNetRawDocumentRecord> DeliveredBySourceRecordKey { get; } =
new(StringComparer.Ordinal);
public bool DemandActive { get; set; }
}
private sealed class ProjectedSnapshot
{
public ProjectedSnapshot(Dictionary<string, CultNetRawDocumentRecord> bySourceRecordKey)
{
BySourceRecordKey = bySourceRecordKey;
Documents = bySourceRecordKey.Values.ToArray();
}
public static ProjectedSnapshot Empty { get; } =
new(new Dictionary<string, CultNetRawDocumentRecord>(StringComparer.Ordinal));
public Dictionary<string, CultNetRawDocumentRecord> BySourceRecordKey { get; }
public CultNetRawDocumentRecord[] Documents { get; }
}
private sealed class SubscriptionKey
{
public SubscriptionKey(ICultNetSchemaServerPeer peer, string id)

View file

@ -164,6 +164,16 @@ namespace GameCult.Networking
if (_disposed) return;
if (request == null) throw new ArgumentNullException(nameof(request));
if (peer == null) throw new ArgumentNullException(nameof(peer));
if (_sourceRuntimeId != null &&
!string.Equals(request.TargetRuntimeId, _sourceRuntimeId, StringComparison.Ordinal))
{
peer.SendCultNet(Failure(
request,
"denied",
"target-runtime-mismatch",
$"Operation targeted runtime '{request.TargetRuntimeId ?? "(missing)"}', not '{_sourceRuntimeId}'."));
return;
}
Route route;
try

View file

@ -72,6 +72,8 @@ namespace GameCult.Networking
CultNetSchemaVersions.SimulationConsensusCandidate => Deserialize<CultNetSimulationConsensusCandidateMessage>(payload),
CultNetSchemaVersions.VerseCatalogRequest => Deserialize<CultMeshVerseCatalogRequestMessage>(payload),
CultNetSchemaVersions.VerseCatalogResponse => Deserialize<CultMeshVerseCatalogResponseMessage>(payload),
CultNetSchemaVersions.SessionOpen => Deserialize<CultMeshSessionOpenMessage>(payload),
CultNetSchemaVersions.SessionAccepted => Deserialize<CultMeshSessionAcceptedMessage>(payload),
CultNetSchemaVersions.PeerExchangeRequest => Deserialize<CultMeshPeerExchangeRequestMessage>(payload),
CultNetSchemaVersions.PeerExchangeResponse => Deserialize<CultMeshPeerExchangeResponseMessage>(payload),
CultNetSchemaVersions.ContentChunkRequest => Deserialize<CultMeshContentChunkRequestMessage>(payload),

View file

@ -135,6 +135,10 @@ namespace GameCult.Networking
/// CultMesh Verse catalog response contract identifier.
/// </summary>
public const string VerseCatalogResponse = "cultmesh.verse_catalog_response.v0";
/// <summary>CultMesh session identity request contract identifier.</summary>
public const string SessionOpen = "cultmesh.session_open.v2";
/// <summary>CultMesh verified session identity response contract identifier.</summary>
public const string SessionAccepted = "cultmesh.session_accepted.v2";
/// <summary>
/// CultMesh peer exchange request contract identifier.
/// </summary>
@ -1179,6 +1183,37 @@ namespace GameCult.Networking
}
}
/// <summary>Requests an identity-bound CultMesh session before application traffic begins.</summary>
[MessagePackObject]
public class CultMeshSessionOpenMessage : ICultNetSchemaMessage
{
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SessionOpen;
[Key("messageId")] public string MessageId { get; set; } = string.Empty;
[Key("sourceRuntimeId")] public string SourceRuntimeId { get; set; } = string.Empty;
[Key("verseId")] public string VerseId { get; set; } = string.Empty;
[Key("authorityRuntimeId")] public string AuthorityRuntimeId { get; set; } = string.Empty;
[Key("protocolId")] public string ProtocolId { get; set; } = string.Empty;
[Key("routeGeneration")] public string RouteGeneration { get; set; } = string.Empty;
[Key("clientNonce")] public string ClientNonce { get; set; } = string.Empty;
}
/// <summary>Accepts or rejects a CultMesh session with the server's actual identity.</summary>
[MessagePackObject]
public class CultMeshSessionAcceptedMessage : ICultNetSchemaMessage
{
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SessionAccepted;
[Key("messageId")] public string MessageId { get; set; } = string.Empty;
[Key("accepted")] public bool Accepted { get; set; }
[Key("verseId")] public string VerseId { get; set; } = string.Empty;
[Key("authorityRuntimeId")] public string AuthorityRuntimeId { get; set; } = string.Empty;
[Key("protocolId")] public string ProtocolId { get; set; } = string.Empty;
[Key("routeGeneration")] public string RouteGeneration { get; set; } = string.Empty;
[Key("clientNonce")] public string ClientNonce { get; set; } = string.Empty;
[Key("providerKeyId")] public string ProviderKeyId { get; set; } = string.Empty;
[Key("providerSignature")] public string ProviderSignature { get; set; } = string.Empty;
[Key("error")] public string? Error { get; set; }
}
/// <summary>
/// Requests known CultMesh Verses from a peer.
/// </summary>
@ -1254,6 +1289,11 @@ namespace GameCult.Networking
/// </summary>
[Key("authorityRuntimeIds")] public string[] AuthorityRuntimeIds { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets exact authoritative-runtime route bindings. This is the
/// routing authority; the parallel legacy arrays are projections only.
/// </summary>
[Key("authorityRoutes")] public CultMeshAuthorityRouteMessage[] AuthorityRoutes { get; set; } = Array.Empty<CultMeshAuthorityRouteMessage>();
/// <summary>
/// Gets or sets the parent Verse id for overlays or branches.
/// </summary>
[Key("parentVerseId")] public string? ParentVerseId { get; set; }
@ -1263,6 +1303,30 @@ namespace GameCult.Networking
[Key("description")] public string? Description { get; set; }
}
/// <summary>Wire representation of one authority-bound CultMesh route.</summary>
[MessagePackObject]
public class CultMeshAuthorityRouteMessage
{
[Key("authorityRuntimeId")] public string AuthorityRuntimeId { get; set; } = string.Empty;
[Key("endpoint")] public string Endpoint { get; set; } = string.Empty;
[Key("protocolIds")] public string[] ProtocolIds { get; set; } = Array.Empty<string>();
[Key("priority")] public int Priority { get; set; }
[Key("generation")] public string Generation { get; set; } = string.Empty;
[Key("certificate")] public CultMeshRouteCertificateMessage? Certificate { get; set; }
}
[MessagePackObject]
public class CultMeshRouteCertificateMessage
{
[Key("providerKeyId")] public string ProviderKeyId { get; set; } = string.Empty;
[Key("providerPublicKeyX")] public string ProviderPublicKeyX { get; set; } = string.Empty;
[Key("providerPublicKeyY")] public string ProviderPublicKeyY { get; set; } = string.Empty;
[Key("odinKeyId")] public string OdinKeyId { get; set; } = string.Empty;
[Key("issuedAtUnixMilliseconds")] public long IssuedAtUnixMilliseconds { get; set; }
[Key("expiresAtUnixMilliseconds")] public long ExpiresAtUnixMilliseconds { get; set; }
[Key("signature")] public string Signature { get; set; } = string.Empty;
}
/// <summary>
/// Wire representation of CultMesh Verse compatibility.
/// </summary>

View file

@ -3,6 +3,7 @@ using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
@ -434,9 +435,10 @@ namespace GameCult.Caching.Tests
}
var manifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath));
Assert.That(manifest.FormatVersion, Is.EqualTo("cultcache.store.v2.directory-indexed"));
Assert.That(manifest.FormatVersion, Is.EqualTo("cultcache.store.v4.directory-content-addressed-pages"));
Assert.That(manifest.Records, Has.Length.EqualTo(2));
Assert.That(manifest.Records.All(record => record.Payload.Length == 0), Is.True);
Assert.That(manifest.Records.All(record => record.Payload.Length == 32), Is.True,
"the hot index carries only the SHA-256 page identity, never the document body");
var coldPath = Directory.GetFiles(recordsPath, "*.msgpack")
.Single(path => string.Equals(
@ -469,7 +471,7 @@ namespace GameCult.Caching.Tests
var finalManifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath));
Assert.That(finalManifest.Records, Has.Length.EqualTo(3));
Assert.That(finalManifest.Records.Any(record => record.Key == cold.Key.Value), Is.True);
Assert.That(finalManifest.Records.All(record => record.Payload.Length == 0), Is.True);
Assert.That(finalManifest.Records.All(record => record.Payload.Length == 32), Is.True);
}
finally
{
@ -572,6 +574,10 @@ namespace GameCult.Caching.Tests
File.WriteAllBytes(recordPath, CultDocumentMessagePackSerialization.SerializePersistedRecord(record));
var readStore = new DirectoryMessagePackBackingStore(filePath, recordsPath);
var rawGeneration = readStore.ReadPersistedGeneration();
Assert.That(rawGeneration.Records.Select(value => value.Key),
Is.EqualTo(new[] { "record:stale" }),
"v1 split record pages remain part of the selected legacy generation");
var readCache = new CultCache();
readCache.AddBackingStore(readStore);
await readCache.PullAllBackingStoresAsync();
@ -599,7 +605,7 @@ namespace GameCult.Caching.Tests
}
[Test]
public async Task DirectoryMessagePackBackingStore_Precommits_Catalog_And_Remains_Dirty_When_Record_Write_Fails()
public async Task DirectoryMessagePackBackingStore_Does_Not_Expose_New_Pages_Before_Manifest_Commit()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc");
var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath);
@ -624,18 +630,21 @@ namespace GameCult.Caching.Tests
alternateDescriptor,
new AlternateNamedTestEntry { Name = "new" }));
using (new FileStream(recordPath, FileMode.Open, FileAccess.Read, FileShare.Read))
store.FlushStageProbe = (stage, _) =>
{
Assert.That(() => store.PushAll(), Throws.Exception);
}
if (string.Equals(stage, DirectoryMessagePackBackingStore.BeforeManifestCommitStage, StringComparison.Ordinal))
throw new IOException("injected crash before manifest commit");
};
Assert.That(() => store.PushAll(), Throws.TypeOf<IOException>());
var precommit = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath));
var alternateSchema = alternateDescriptor.SchemaId;
Assert.That(precommit.SchemaCatalog.Select(entry => entry.SchemaId), Does.Contain(durableSchema));
Assert.That(precommit.SchemaCatalog.Select(entry => entry.SchemaId), Does.Contain(alternateSchema));
Assert.That(precommit.SchemaCatalog.Select(entry => entry.SchemaId), Does.Not.Contain(alternateSchema));
Assert.That(store.IsDirty, Is.True);
Assert.That(CultDocumentMessagePackSerialization.DeserializePersistedRecord(File.ReadAllBytes(recordPath)).SchemaId, Is.EqualTo(durableSchema));
store.FlushStageProbe = null;
store.PushAll();
var compacted = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath));
@ -649,6 +658,288 @@ namespace GameCult.Caching.Tests
}
}
[TestCase(0)]
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public async Task DirectoryMessagePackBackingStore_Fault_Before_Manifest_Leaves_Entire_Previous_Generation(int writtenPageCount)
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc");
var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath);
try
{
var store = new DirectoryMessagePackBackingStore(filePath, recordsPath);
var cache = new CultCache();
cache.AddBackingStore(store);
var handles = new[]
{
new CultRecordHandle<NamedTestEntry>(new CultRecordKey("record:atomic:a")),
new CultRecordHandle<NamedTestEntry>(new CultRecordKey("record:atomic:b")),
new CultRecordHandle<NamedTestEntry>(new CultRecordKey("record:atomic:c"))
};
foreach (var handle in handles)
await cache.UpsertAsync(new NamedTestEntry { Name = handle.Key.Value, Value = "old" }, handle);
store.PushAll();
foreach (var handle in handles)
await cache.UpsertAsync(new NamedTestEntry { Name = handle.Key.Value, Value = "new" }, handle);
store.FlushStageProbe = (stage, index) =>
{
if ((writtenPageCount < handles.Length &&
string.Equals(stage, DirectoryMessagePackBackingStore.BeforeRecordPageWriteStage, StringComparison.Ordinal) &&
index == writtenPageCount) ||
(writtenPageCount == handles.Length &&
string.Equals(stage, DirectoryMessagePackBackingStore.BeforeManifestCommitStage, StringComparison.Ordinal)))
throw new IOException($"injected crash after {writtenPageCount} pages");
};
Assert.That(() => store.PushAll(), Throws.TypeOf<IOException>());
using (var reopened = await CultCacheMessagePack.OpenAsync(
filePath,
new CultCacheOpenOptions { UseDirectoryStore = true }))
{
foreach (var handle in handles)
Assert.That(reopened.Get<NamedTestEntry>(handle.Key)?.Value, Is.EqualTo("old"));
}
store.FlushStageProbe = null;
store.PushAll();
using (var reopened = await CultCacheMessagePack.OpenAsync(
filePath,
new CultCacheOpenOptions { UseDirectoryStore = true }))
{
foreach (var handle in handles)
Assert.That(reopened.Get<NamedTestEntry>(handle.Key)?.Value, Is.EqualTo("new"));
}
}
finally
{
if (File.Exists(filePath)) File.Delete(filePath);
if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true);
}
}
[Test]
public async Task DirectoryMessagePackBackingStore_SameTimestampPayload_CannotOverwriteCommittedPageBeforeManifest()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc");
var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath);
try
{
const string storedAt = "2026-08-19T00:00:00.0000000+00:00";
var store = new DirectoryMessagePackBackingStore(filePath, recordsPath);
var cache = new CultCache();
cache.AddBackingStore(store);
var key = new CultRecordKey("record:atomic:same-timestamp");
var descriptor = CultDocumentRegistry.Shared.GetRequired<NamedTestEntry>();
store.Push(new CultStoredDocument(
key,
storedAt,
descriptor,
new NamedTestEntry { Name = "same", Value = "old" }));
store.PushAll();
store.Push(new CultStoredDocument(
key,
storedAt,
descriptor,
new NamedTestEntry { Name = "same", Value = "new" }));
store.FlushStageProbe = (stage, _) =>
{
if (string.Equals(stage, DirectoryMessagePackBackingStore.BeforeManifestCommitStage, StringComparison.Ordinal))
throw new IOException("injected crash before same-timestamp manifest commit");
};
Assert.That(() => store.PushAll(), Throws.TypeOf<IOException>());
using var reopened = await CultCacheMessagePack.OpenAsync(
filePath,
new CultCacheOpenOptions { UseDirectoryStore = true });
Assert.That(reopened.Get<NamedTestEntry>(key)?.Value, Is.EqualTo("old"));
}
finally
{
if (File.Exists(filePath)) File.Delete(filePath);
if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true);
}
}
[Test]
public async Task DirectoryMessagePackBackingStore_MutationDuringFlush_RemainsDirtyAndCommitsNextGeneration()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc");
var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath);
try
{
var store = new DirectoryMessagePackBackingStore(filePath, recordsPath);
var cache = new CultCache();
cache.AddBackingStore(store);
var first = await cache.UpsertAsync(new NamedTestEntry { Name = "first", Value = "one" });
using var flushEntered = new ManualResetEventSlim();
using var writerAttempting = new ManualResetEventSlim();
store.FlushStageProbe = (stage, _) =>
{
if (!string.Equals(stage, DirectoryMessagePackBackingStore.BeforeManifestCommitStage, StringComparison.Ordinal))
return;
flushEntered.Set();
Assert.That(writerAttempting.Wait(TimeSpan.FromSeconds(5)), Is.True);
};
var flush = Task.Run(() => store.PushAll());
var writer = Task.Run(async () =>
{
Assert.That(flushEntered.Wait(TimeSpan.FromSeconds(5)), Is.True);
writerAttempting.Set();
return await cache.UpsertAsync(new NamedTestEntry { Name = "second", Value = "two" });
});
await flush;
var second = await writer;
Assert.That(store.IsDirty, Is.True, "the mutation blocked behind flush must remain pending");
store.FlushStageProbe = null;
store.PushAll();
using var reopened = await CultCacheMessagePack.OpenAsync(
filePath,
new CultCacheOpenOptions { UseDirectoryStore = true });
Assert.That(reopened.Get<NamedTestEntry>(first.Key)?.Value, Is.EqualTo("one"));
Assert.That(reopened.Get<NamedTestEntry>(second.Key)?.Value, Is.EqualTo("two"));
}
finally
{
if (File.Exists(filePath)) File.Delete(filePath);
if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true);
}
}
[Test]
public async Task DirectoryMessagePackBackingStore_ConcurrentInstances_MergeUnderOneCommitLease()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc");
var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath);
try
{
var firstStore = new DirectoryMessagePackBackingStore(filePath, recordsPath);
var firstCache = new CultCache();
firstCache.AddBackingStore(firstStore);
var secondStore = new DirectoryMessagePackBackingStore(filePath, recordsPath);
var secondCache = new CultCache();
secondCache.AddBackingStore(secondStore);
var first = await firstCache.UpsertAsync(new NamedTestEntry { Name = "first-process", Value = "one" });
var second = await secondCache.UpsertAsync(new NamedTestEntry { Name = "second-process", Value = "two" });
await Task.WhenAll(
Task.Run(() => firstStore.PushAll()),
Task.Run(() => secondStore.PushAll()));
using var reopened = await CultCacheMessagePack.OpenAsync(
filePath,
new CultCacheOpenOptions { UseDirectoryStore = true });
Assert.That(reopened.Get<NamedTestEntry>(first.Key)?.Value, Is.EqualTo("one"));
Assert.That(reopened.Get<NamedTestEntry>(second.Key)?.Value, Is.EqualTo("two"));
}
finally
{
if (File.Exists(filePath)) File.Delete(filePath);
if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true);
}
}
[Test]
public async Task DirectoryMessagePackBackingStore_ReaderLeasesManifestUntilEveryReferencedPageIsHydrated()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc");
var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath);
try
{
var key = new CultRecordKey("record:reader-generation");
var writerStore = new DirectoryMessagePackBackingStore(filePath, recordsPath);
var writerCache = new CultCache();
writerCache.AddBackingStore(writerStore);
await writerCache.UpsertAsync(
new NamedTestEntry { Name = "generation", Value = "old" },
new CultRecordHandle<NamedTestEntry>(key));
writerStore.PushAll();
var readerStore = new DirectoryMessagePackBackingStore(filePath, recordsPath);
var readerCache = new CultCache();
readerCache.AddBackingStore(readerStore);
using var manifestRead = new ManualResetEventSlim();
using var releaseReader = new ManualResetEventSlim();
readerStore.ReadStageProbe = stage =>
{
if (!string.Equals(stage, DirectoryMessagePackBackingStore.AfterManifestReadStage, StringComparison.Ordinal))
return;
manifestRead.Set();
Assert.That(releaseReader.Wait(TimeSpan.FromSeconds(5)), Is.True);
};
var read = Task.Run(() => readerStore.PullAll());
Assert.That(manifestRead.Wait(TimeSpan.FromSeconds(5)), Is.True);
await writerCache.UpsertAsync(
new NamedTestEntry { Name = "generation", Value = "new" },
new CultRecordHandle<NamedTestEntry>(key));
var write = Task.Run(() => writerStore.PushAll());
Assert.That(write.Wait(TimeSpan.FromMilliseconds(100)), Is.False,
"writer must wait while a reader hydrates its selected manifest generation");
releaseReader.Set();
await Task.WhenAll(read, write);
Assert.That(readerCache.Get<NamedTestEntry>(key)?.Value, Is.EqualTo("old"));
using var reopened = await CultCacheMessagePack.OpenAsync(
filePath,
new CultCacheOpenOptions { UseDirectoryStore = true });
Assert.That(reopened.Get<NamedTestEntry>(key)?.Value, Is.EqualTo("new"));
}
finally
{
if (File.Exists(filePath)) File.Delete(filePath);
if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true);
}
}
[Test]
public async Task DirectoryMessagePackBackingStore_ReleasesGenerationLeaseBeforePublishingObservers()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc");
var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath);
try
{
var writerStore = new DirectoryMessagePackBackingStore(filePath, recordsPath);
var writerCache = new CultCache();
writerCache.AddBackingStore(writerStore);
await writerCache.UpsertAsync(new NamedTestEntry { Name = "source", Value = "one" });
writerStore.PushAll();
var readerStore = new DirectoryMessagePackBackingStore(filePath, recordsPath);
var readerCache = new CultCache();
readerCache.AddBackingStore(readerStore);
var observerRan = false;
using var subscription = readerStore.EntryAdded.Subscribe(_ =>
{
if (observerRan)
return;
observerRan = true;
readerCache.UpsertAsync(new NamedTestEntry { Name = "observer", Value = "two" })
.GetAwaiter()
.GetResult();
readerStore.PushAll();
});
Assert.That(() => readerStore.PullAll(), Throws.Nothing);
Assert.That(observerRan, Is.True);
using var reopened = await CultCacheMessagePack.OpenAsync(
filePath,
new CultCacheOpenOptions { UseDirectoryStore = true });
Assert.That(reopened.GetAll<NamedTestEntry>().Select(value => value.Name),
Is.EquivalentTo(new[] { "source", "observer" }));
}
finally
{
if (File.Exists(filePath)) File.Delete(filePath);
if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true);
}
}
[Test]
public async Task DirectoryMessagePackBackingStore_PullsOnlyExternalRecordDeltas()
{
@ -743,7 +1034,7 @@ namespace GameCult.Caching.Tests
}
[Test]
public async Task DirectoryMessagePackBackingStore_Remains_Dirty_When_Deletion_Fails()
public async Task DirectoryMessagePackBackingStore_Deletion_Commits_While_Locked_Old_Page_Remains_An_Orphan()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc");
var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath);
@ -760,18 +1051,17 @@ namespace GameCult.Caching.Tests
using (new FileStream(recordPath, FileMode.Open, FileAccess.Read, FileShare.Read))
{
Assert.That(() => store.PushAll(), Throws.Exception);
Assert.That(() => store.PushAll(), Throws.Nothing);
Assert.That(File.Exists(recordPath), Is.True);
}
Assert.That(File.Exists(recordPath), Is.True);
Assert.That(store.IsDirty, Is.True);
store.PushAll();
Assert.That(File.Exists(recordPath), Is.False);
Assert.That(store.IsDirty, Is.False);
var compacted = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath));
Assert.That(compacted.SchemaCatalog, Is.Empty);
Assert.That(compacted.Records, Is.Empty);
var selectedGeneration = store.ReadPersistedGeneration();
Assert.That(selectedGeneration.Records, Is.Empty,
"a raw generation reader must not resurrect an orphaned page left by another process");
}
finally
{
@ -1052,6 +1342,274 @@ namespace GameCult.Caching.Tests
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("changed target schema"));
}
[Test]
public async Task CultCache_Transaction_Hides_Staged_Records_Until_Whole_Batch_Is_Durable()
{
var root = Path.Combine(Path.GetTempPath(), $"cultcache-transaction-{Guid.NewGuid():N}");
var manifest = Path.Combine(root, "state.cc");
Directory.CreateDirectory(root);
try
{
using var cache = new CultCache();
cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
var firstKey = new CultRecordKey("transaction:first");
var secondKey = new CultRecordKey("transaction:second");
var staged = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var observed = 0;
using var subscription = cache.Watch<NamedTestEntry>().Subscribe(_ => observed++);
var transaction = cache.ExecuteTransactionAsync(async () =>
{
await cache.UpsertAsync(
new NamedTestEntry { Name = "first", Value = "one" },
new CultRecordHandle<NamedTestEntry>(firstKey));
staged.SetResult(true);
await release.Task;
await cache.UpsertAsync(
new NamedTestEntry { Name = "second", Value = "two" },
new CultRecordHandle<NamedTestEntry>(secondKey));
});
await staged.Task;
Assert.That(cache.Get<NamedTestEntry>(firstKey), Is.Null);
Assert.That(observed, Is.Zero);
release.SetResult(true);
await transaction;
Assert.That(cache.Get<NamedTestEntry>(firstKey)?.Value, Is.EqualTo("one"));
Assert.That(cache.Get<NamedTestEntry>(secondKey)?.Value, Is.EqualTo("two"));
Assert.That(observed, Is.EqualTo(2));
using var reopened = new CultCache();
reopened.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
await reopened.PullAllBackingStoresAsync();
Assert.That(reopened.Get<NamedTestEntry>(firstKey)?.Value, Is.EqualTo("one"));
Assert.That(reopened.Get<NamedTestEntry>(secondKey)?.Value, Is.EqualTo("two"));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
[Test]
public async Task CultCache_Transaction_Abort_Cannot_Leak_Into_Later_Commit()
{
var root = Path.Combine(Path.GetTempPath(), $"cultcache-abort-{Guid.NewGuid():N}");
var manifest = Path.Combine(root, "state.cc");
Directory.CreateDirectory(root);
try
{
using var cache = new CultCache();
cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
var abortedKey = new CultRecordKey("transaction:aborted");
var committedKey = new CultRecordKey("transaction:committed");
var observed = 0;
using var subscription = cache.Watch<NamedTestEntry>().Subscribe(_ => observed++);
Assert.ThrowsAsync<InvalidOperationException>(async () =>
await cache.ExecuteTransactionAsync(async () =>
{
await cache.UpsertAsync(
new NamedTestEntry { Name = "aborted", Value = "must-not-leak" },
new CultRecordHandle<NamedTestEntry>(abortedKey));
throw new InvalidOperationException("abort probe");
}));
Assert.That(cache.Get<NamedTestEntry>(abortedKey), Is.Null);
Assert.That(observed, Is.Zero);
await cache.ExecuteTransactionAsync(async () =>
{
await cache.UpsertAsync(
new NamedTestEntry { Name = "committed", Value = "survives" },
new CultRecordHandle<NamedTestEntry>(committedKey));
});
Assert.That(observed, Is.EqualTo(1));
using var reopened = new CultCache();
reopened.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
await reopened.PullAllBackingStoresAsync();
Assert.That(reopened.Get<NamedTestEntry>(abortedKey), Is.Null);
Assert.That(reopened.Get<NamedTestEntry>(committedKey)?.Value, Is.EqualTo("survives"));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
[Test]
public async Task CultCache_Transaction_Rejects_Escaped_Async_Writes_After_Commit()
{
var root = Path.Combine(Path.GetTempPath(), $"cultcache-escaped-transaction-{Guid.NewGuid():N}");
var manifest = Path.Combine(root, "state.cc");
Directory.CreateDirectory(root);
try
{
using var cache = new CultCache();
cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
var committedKey = new CultRecordKey("transaction:committed-parent");
var escapedKey = new CultRecordKey("transaction:escaped-child");
var releaseChild = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
Task? escapedWrite = null;
await cache.ExecuteTransactionAsync(async () =>
{
await cache.UpsertAsync(
new NamedTestEntry { Name = "parent", Value = "committed" },
new CultRecordHandle<NamedTestEntry>(committedKey));
escapedWrite = Task.Run(async () =>
{
await releaseChild.Task;
await cache.UpsertAsync(
new NamedTestEntry { Name = "child", Value = "must-fail" },
new CultRecordHandle<NamedTestEntry>(escapedKey));
});
});
releaseChild.SetResult(true);
Assert.That(escapedWrite, Is.Not.Null);
Assert.That(
async () => await escapedWrite!,
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("already completed"));
Assert.That(cache.Get<NamedTestEntry>(committedKey)?.Value, Is.EqualTo("committed"));
Assert.That(cache.Get<NamedTestEntry>(escapedKey), Is.Null);
using var reopened = new CultCache();
reopened.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
await reopened.PullAllBackingStoresAsync();
Assert.That(reopened.Get<NamedTestEntry>(committedKey)?.Value, Is.EqualTo("committed"));
Assert.That(reopened.Get<NamedTestEntry>(escapedKey), Is.Null);
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
[Test]
public async Task CultCache_Transaction_Rejects_Hydration_Inside_Its_Mutation_Scope()
{
var root = Path.Combine(Path.GetTempPath(), $"cultcache-transaction-hydration-{Guid.NewGuid():N}");
var manifest = Path.Combine(root, "state.cc");
Directory.CreateDirectory(root);
try
{
using (var writer = new CultCache())
{
writer.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
await writer.UpsertAsync(
new NamedTestEntry { Name = "external", Value = "durable" },
new CultRecordHandle<NamedTestEntry>(new CultRecordKey("transaction:external")));
writer.FlushAllBackingStores();
}
using var reader = new CultCache();
reader.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
var observed = 0;
using var subscription = reader.Watch<NamedTestEntry>().Subscribe(_ => observed++);
Assert.That(
async () => await reader.ExecuteTransactionAsync(
() => reader.PullAllBackingStoresAsync()),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("hydrate before"));
Assert.That(reader.Get<NamedTestEntry>(new CultRecordKey("transaction:external")), Is.Null);
Assert.That(observed, Is.Zero);
await reader.PullAllBackingStoresAsync();
Assert.That(reader.Get<NamedTestEntry>(new CultRecordKey("transaction:external"))?.Value,
Is.EqualTo("durable"));
Assert.That(observed, Is.EqualTo(1));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
[Test]
public async Task CultCache_Hydration_Waits_For_The_Active_Mutation_Transaction()
{
var root = Path.Combine(Path.GetTempPath(), $"cultcache-hydration-gate-{Guid.NewGuid():N}");
var manifest = Path.Combine(root, "state.cc");
Directory.CreateDirectory(root);
try
{
using var cache = new CultCache();
cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
var staged = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var transaction = cache.ExecuteTransactionAsync(async () =>
{
await cache.UpsertAsync(
new NamedTestEntry { Name = "staged", Value = "committed" },
new CultRecordHandle<NamedTestEntry>(new CultRecordKey("transaction:hydration-gate")));
staged.SetResult(true);
await release.Task;
});
await staged.Task;
var hydration = cache.PullAllBackingStoresAsync();
Assert.That(hydration.IsCompleted, Is.False,
"hydration must wait until the active mutation transaction has committed");
release.SetResult(true);
await transaction;
await hydration;
Assert.That(cache.Get<NamedTestEntry>(new CultRecordKey("transaction:hydration-gate"))?.Value,
Is.EqualTo("committed"));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
[Test]
public async Task CultCache_Transaction_Observer_Writes_In_A_New_Commit()
{
var root = Path.Combine(Path.GetTempPath(), $"cultcache-observer-transaction-{Guid.NewGuid():N}");
var manifest = Path.Combine(root, "state.cc");
Directory.CreateDirectory(root);
try
{
using var cache = new CultCache();
cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
var firstKey = new CultRecordKey("transaction:observer-source");
var secondKey = new CultRecordKey("transaction:observer-write");
using var subscription = cache.Watch<NamedTestEntry>().Subscribe(change =>
{
if (!change.Key.Equals(firstKey)) return;
cache.ExecuteTransactionAsync(async () =>
{
await cache.UpsertAsync(
new NamedTestEntry { Name = "observer", Value = "separate-commit" },
new CultRecordHandle<NamedTestEntry>(secondKey));
}).GetAwaiter().GetResult();
});
await cache.ExecuteTransactionAsync(async () =>
{
await cache.UpsertAsync(
new NamedTestEntry { Name = "source", Value = "committed-first" },
new CultRecordHandle<NamedTestEntry>(firstKey));
});
Assert.That(cache.Get<NamedTestEntry>(secondKey)?.Value, Is.EqualTo("separate-commit"));
using var reopened = new CultCache();
reopened.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
await reopened.PullAllBackingStoresAsync();
Assert.That(reopened.Get<NamedTestEntry>(firstKey)?.Value, Is.EqualTo("committed-first"));
Assert.That(reopened.Get<NamedTestEntry>(secondKey)?.Value, Is.EqualTo("separate-commit"));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
private const string NamedFixtureCanonicalSchemaJson =
"{\"schemaName\":\"tests.named_entry\",\"schemaVersion\":\"tests.named_entry.v1\",\"members\":[{\"slot\":0,\"name\":\"Name\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":true},{\"slot\":1,\"name\":\"Value\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false}]}";
private const string NamedFixtureSchemaId = "sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8";

View file

@ -22,7 +22,7 @@ public sealed class CultMeshNativeQuicRealtimeTransportTests
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
using var client = await connector.ConnectAsync(
new CultMeshTransportCandidate(endpoint!),
CultMeshEndpointId.Parse("aetheria.local"));
new CultMeshSessionTarget("aetheria", "aetheria.local"));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var frame = await client.ReceiveAsync(timeout.Token);
@ -49,7 +49,7 @@ public sealed class CultMeshNativeQuicRealtimeTransportTests
using var client = await connector.ConnectAsync(
new CultMeshTransportCandidate(endpoint),
CultMeshEndpointId.Parse("aetheria.local"));
new CultMeshSessionTarget("aetheria", "aetheria.local"));
var expected = new CultMeshRealtimeFrame
{
ChannelId = "aetheria.entities",
@ -92,7 +92,7 @@ public sealed class CultMeshNativeQuicRealtimeTransportTests
var connect = () => connector.ConnectAsync(
new CultMeshTransportCandidate(endpoint),
CultMeshEndpointId.Parse("aetheria.local"));
new CultMeshSessionTarget("aetheria", "aetheria.local"));
await connect.Should().ThrowAsync<IOException>()
.WithMessage("*certificate*pin*");
@ -114,10 +114,10 @@ public sealed class CultMeshNativeQuicRealtimeTransportTests
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
var departedClient = await connector.ConnectAsync(
new CultMeshTransportCandidate(endpoint),
CultMeshEndpointId.Parse("aetheria.local"));
new CultMeshSessionTarget("aetheria", "aetheria.local"));
using var healthyClient = await connector.ConnectAsync(
new CultMeshTransportCandidate(endpoint),
CultMeshEndpointId.Parse("aetheria.local"));
new CultMeshSessionTarget("aetheria", "aetheria.local"));
await WaitUntilAsync(() => server.ConnectionCount == 2);
await server.BroadcastAsync(Frame(sequence: 1));
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));

View file

@ -40,7 +40,7 @@ public sealed class CultMeshQuicRealtimeTransportTests
});
using var client = await connector.ConnectAsync(
new CultMeshTransportCandidate($"cultmesh-state+quic://127.0.0.1:{listener.LocalEndPoint.Port}"),
CultMeshEndpointId.Parse("service:aetheria.daemon"));
new CultMeshSessionTarget("aetheria", "service:aetheria.daemon"));
await using var serverConnection = await accept;
client.TransportId.Should().Be("msquic-realtime");
@ -62,7 +62,7 @@ public sealed class CultMeshQuicRealtimeTransportTests
using var client = await connector.ConnectAsync(
new CultMeshTransportCandidate(endpoint),
CultMeshEndpointId.Parse("service:aetheria.daemon"));
new CultMeshSessionTarget("aetheria", "service:aetheria.daemon"));
client.TransportId.Should().Be("msquic-realtime");
await WaitUntilAsync(() => server.ConnectionCount == 1);
@ -78,8 +78,8 @@ public sealed class CultMeshQuicRealtimeTransportTests
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
ServerCertificate = certificate
});
var endpointId = CultMeshEndpointId.Parse("service:aetheria.daemon");
var target = new CultMeshSessionTarget("aetheria", endpointId.Value);
var runtimeId = CultMeshRuntimeId.Parse("service:aetheria.daemon");
var target = new CultMeshSessionTarget("aetheria", runtimeId.Value);
using var sessions = CreateSessions(server, out var validatedIdentity);
CultMeshRealtimeSession session;
try
@ -105,7 +105,7 @@ public sealed class CultMeshQuicRealtimeTransportTests
new[] { firstAtServer.Sequence, secondAtServer.Sequence }.Should().Equal(1, 2);
new[] { firstAtClient.Sequence, secondAtClient.Sequence }.Should().Equal(3, 4);
firstAtClient.Payload.ToArray().Should().Equal(3, 4, 5);
validatedIdentity.Value.Should().Be(endpointId.Value);
validatedIdentity.Value.Should().Be(runtimeId.Value);
}
[Test]
@ -118,8 +118,8 @@ public sealed class CultMeshQuicRealtimeTransportTests
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
ServerCertificate = certificate
});
var endpointId = CultMeshEndpointId.Parse("service:aetheria.daemon");
var target = new CultMeshSessionTarget("aetheria", endpointId.Value);
var runtimeId = CultMeshRuntimeId.Parse("service:aetheria.daemon");
var target = new CultMeshSessionTarget("aetheria", runtimeId.Value);
using var sessions = CreateSessions(server, out _);
var session = await sessions.ConnectRealtimeAsync(target);
await WaitUntilAsync(() => server.ConnectionCount == 1);
@ -148,8 +148,8 @@ public sealed class CultMeshQuicRealtimeTransportTests
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
ServerCertificate = certificate
});
var endpointId = CultMeshEndpointId.Parse("service:aetheria.daemon");
var target = new CultMeshSessionTarget("aetheria", endpointId.Value);
var runtimeId = CultMeshRuntimeId.Parse("service:aetheria.daemon");
var target = new CultMeshSessionTarget("aetheria", runtimeId.Value);
using var sessions = CreateSessions(server, out _);
var session = await sessions.ConnectRealtimeAsync(target);
@ -179,7 +179,11 @@ public sealed class CultMeshQuicRealtimeTransportTests
discovery,
Array.Empty<ICultMeshTransportConnector>(),
Array.Empty<ICultMeshContentTransportConnector>(),
new ICultMeshRealtimeTransportConnector[] { connector });
new ICultMeshRealtimeTransportConnector[] { connector },
new CultMeshSessionManagerOptions
{
Trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment)
});
}
private static CultMeshRealtimeFrame Frame(

View file

@ -0,0 +1,407 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using GameCult.Networking;
using NUnit.Framework;
namespace GameCult.Mesh.Tests;
public sealed class CultMeshAuthorityProofTests
{
private static readonly DateTimeOffset Now = DateTimeOffset.FromUnixTimeMilliseconds(1_800_000_000_000);
[Test]
public void SignedRouteBindsEveryAuthorityFieldToConsumerTrust()
{
using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var route = SignedRoute(odin, provider);
var trust = Trust(odin);
trust.Validate("aetheria", route, Now);
var wireRoundTrip = new CultMeshVerseDescriptor(
"aetheria",
"Aetheria",
CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
authorityRoutes: new[] { route })
.ToMessage()
.ToVerseDescriptor()
.AuthorityRoutes[0];
trust.Validate("aetheria", wireRoundTrip, Now);
wireRoundTrip.Certificate!.ProviderKey.KeyId.Should().Be("provider-1");
Action mutatedEndpoint = () => trust.Validate("aetheria", new CultMeshAuthorityRoute(
route.AuthorityRuntimeId,
"wss://evil.example/mesh",
route.ProtocolIds,
route.Priority,
route.Generation,
route.Certificate), Now);
mutatedEndpoint.Should().Throw<CultMeshSessionException>()
.Which.Failure.Reason.Should().Be(CultMeshSessionFailureReason.Authentication);
}
[Test]
public void RemoteTrustRejectsUnknownExpiredAndUnsignedRoutes()
{
using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var stranger = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var route = SignedRoute(odin, provider);
Action unknownRoot = () => Trust(stranger).Validate("aetheria", route, Now);
Action expired = () => Trust(odin).Validate("aetheria", route, Now.AddHours(2));
Action unsigned = () => Trust(odin).Validate("aetheria", new CultMeshAuthorityRoute(
"aetheria-daemon", "wss://provider.example/mesh",
new[] { CultMeshProtocols.Documents.Value }, generation: "generation-1"), Now);
unknownRoot.Should().Throw<CultMeshSessionException>();
expired.Should().Throw<CultMeshSessionException>();
unsigned.Should().Throw<CultMeshSessionException>();
}
[Test]
public void UnsignedRouteRequiresExplicitLoopbackDevelopmentPolicy()
{
var local = new CultMeshAuthorityRoute(
"aetheria-daemon", "rudp://127.0.0.1:3076",
new[] { CultMeshProtocols.Documents.Value }, generation: "local");
var remote = new CultMeshAuthorityRoute(
"aetheria-daemon", "rudp://192.0.2.10:3076",
new[] { CultMeshProtocols.Documents.Value }, generation: "remote");
var policy = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment);
policy.Validate("aetheria", local, Now);
Action remoteAttempt = () => policy.Validate("aetheria", remote, Now);
remoteAttempt.Should().Throw<CultMeshSessionException>();
}
[Test]
public void ProviderProofRejectsCredentialFreeEchoAndNonceReplay()
{
using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var route = SignedRoute(odin, provider);
var trust = Trust(odin);
var signer = new CultMeshSessionProofSigner(route, provider);
var request = Request("nonce-a");
var signed = Accepted(request, signer.Sign(request));
CultMeshAuthorityProof.VerifySessionProof(request, signed, "aetheria", route, trust, Now).Should().BeTrue();
var echo = Accepted(request, string.Empty);
CultMeshAuthorityProof.VerifySessionProof(request, echo, "aetheria", route, trust, Now).Should().BeFalse();
var replayedRequest = Request("nonce-b");
CultMeshAuthorityProof.VerifySessionProof(replayedRequest, signed, "aetheria", route, trust, Now).Should().BeFalse();
}
[Test]
public async Task SessionManagerRejectsPeerThatOnlyEchoesCertifiedIdentityStrings()
{
using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var now = DateTimeOffset.UtcNow;
var route = CultMeshAuthorityProof.CreateSignedRoute(
"aetheria", "aetheria-daemon", "wss://provider.example/mesh",
new[] { CultMeshProtocols.Documents.Value }, 0, "generation-live",
CultMeshEcdsaP256PublicKey.From("provider-live", provider), "odin-live",
now.AddMinutes(-1), now.AddMinutes(5), odin);
var descriptor = new CultMeshVerseDescriptor(
"aetheria", "Aetheria", CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
authorityRoutes: new[] { route });
using var discovery = new CultMeshDiscoveryService(new[] { new SignedRouteSource(descriptor) });
using var manager = new CultMeshSessionManager(
discovery,
new[] { new EchoConnector() },
new CultMeshSessionManagerOptions
{
Trust = new CultMeshAuthorityTrustPolicy(
CultMeshAuthorityTrustMode.AuthenticatedRemote,
new[] { CultMeshEcdsaP256PublicKey.From("odin-live", odin) })
});
Func<Task> connect = async () => await manager.ConnectAsync(
new CultMeshSessionTarget("aetheria", "aetheria-daemon"),
CultMeshProtocols.Documents);
(await connect.Should().ThrowAsync<CultMeshSessionException>())
.Which.Failure.Reason.Should().Be(CultMeshSessionFailureReason.Authority);
}
[Test]
public async Task SessionManagerRejectsOnlyThePoisonedRouteAndUsesCertifiedFallback()
{
using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var now = DateTimeOffset.UtcNow;
var valid = CultMeshAuthorityProof.CreateSignedRoute(
"aetheria", "aetheria-daemon", "wss://provider.example/mesh",
new[] { CultMeshProtocols.Documents.Value }, 10, "generation-valid",
CultMeshEcdsaP256PublicKey.From("provider-valid", provider), "odin-valid",
now.AddMinutes(-1), now.AddMinutes(5), odin);
var poisoned = new CultMeshAuthorityRoute(
"aetheria-daemon", "wss://poison.example/mesh",
new[] { CultMeshProtocols.Documents.Value }, priority: 0, generation: "generation-poisoned");
var descriptor = new CultMeshVerseDescriptor(
"aetheria", "Aetheria", CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
authorityRoutes: new[] { poisoned, valid });
using var discovery = new CultMeshDiscoveryService(new[] { new SignedRouteSource(descriptor) });
var signer = new CultMeshSessionProofSigner(valid, provider);
var connector = new SigningConnector(signer);
using var manager = new CultMeshSessionManager(
discovery,
new[] { connector },
new CultMeshSessionManagerOptions
{
Trust = new CultMeshAuthorityTrustPolicy(
CultMeshAuthorityTrustMode.AuthenticatedRemote,
new[] { CultMeshEcdsaP256PublicKey.From("odin-valid", odin) })
});
var session = await manager.ConnectAsync(
new CultMeshSessionTarget("aetheria", "aetheria-daemon"),
CultMeshProtocols.Documents);
session.State.Path!.Endpoint.Should().Be(valid.Endpoint);
connector.Attempts.Should().Equal(valid.Endpoint);
}
[Test]
public async Task AuthenticatedRemoteContentUsesCertifiedHttpsAndStreamsTheAdvertisedChunk()
{
using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var payload = Encoding.UTF8.GetBytes("certified-content");
var route = CultMeshAuthorityProof.CreateSignedRoute(
"aetheria", "aetheria-daemon", "https://provider.example/cultmesh/content",
new[] { CultMeshProtocols.Content.Value }, 0, "content-generation",
CultMeshEcdsaP256PublicKey.From("provider-content", provider), "odin-content",
Now.AddMinutes(-1), Now.AddMinutes(5), odin);
var descriptor = new CultMeshVerseDescriptor(
"aetheria", "Aetheria", CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
authorityRoutes: new[] { route });
using var discovery = new CultMeshDiscoveryService(new[] { new SignedRouteSource(descriptor) });
var handler = new FixedContentHandler(payload);
using var manager = new CultMeshSessionManager(
discovery,
Array.Empty<ICultMeshTransportConnector>(),
new ICultMeshContentTransportConnector[]
{
new CultMeshHttpsContentTransportConnector(() => handler)
},
new CultMeshSessionManagerOptions
{
Trust = new CultMeshAuthorityTrustPolicy(
CultMeshAuthorityTrustMode.AuthenticatedRemote,
new[] { CultMeshEcdsaP256PublicKey.From("odin-content", odin) }),
Clock = new FixedClock(Now)
});
var session = await manager.ConnectContentAsync(
new CultMeshSessionTarget("aetheria", "aetheria-daemon"));
using var destination = new MemoryStream();
await session.CopyChunkToAsync(new CultMeshCdnChunkRef
{
ChunkHash = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant(),
RecordKey = "cdn:chunk:certified",
SizeBytes = payload.Length
}, destination);
destination.ToArray().Should().Equal(payload);
handler.RequestUri.Should().NotBeNull();
handler.RequestUri!.Scheme.Should().Be(Uri.UriSchemeHttps);
handler.RequestUri.Query.Should().Contain("chunkHash=").And.Contain("recordKey=");
}
private static CultMeshAuthorityRoute SignedRoute(ECDsa odin, ECDsa provider) =>
CultMeshAuthorityProof.CreateSignedRoute(
"aetheria",
"aetheria-daemon",
"wss://provider.example/mesh",
new[] { CultMeshProtocols.Documents.Value },
0,
"generation-1",
CultMeshEcdsaP256PublicKey.From("provider-1", provider),
"odin-root-1",
Now.AddMinutes(-1),
Now.AddHours(1),
odin);
private static CultMeshAuthorityTrustPolicy Trust(ECDsa odin) => new(
CultMeshAuthorityTrustMode.AuthenticatedRemote,
new[] { CultMeshEcdsaP256PublicKey.From("odin-root-1", odin) });
private static GameCult.Networking.CultMeshSessionOpenMessage Request(string nonceLabel)
{
using var hash = SHA256.Create();
return new GameCult.Networking.CultMeshSessionOpenMessage
{
MessageId = "message-1",
SourceRuntimeId = "browser-1",
VerseId = "aetheria",
AuthorityRuntimeId = "aetheria-daemon",
ProtocolId = CultMeshProtocols.Documents.Value,
RouteGeneration = "generation-1",
ClientNonce = Convert.ToBase64String(hash.ComputeHash(System.Text.Encoding.UTF8.GetBytes(nonceLabel)))
};
}
private static GameCult.Networking.CultMeshSessionAcceptedMessage Accepted(
GameCult.Networking.CultMeshSessionOpenMessage request,
string signature) => new()
{
MessageId = request.MessageId,
Accepted = true,
VerseId = request.VerseId,
AuthorityRuntimeId = request.AuthorityRuntimeId,
ProtocolId = request.ProtocolId,
RouteGeneration = request.RouteGeneration,
ClientNonce = request.ClientNonce,
ProviderKeyId = "provider-1",
ProviderSignature = signature
};
private sealed class SignedRouteSource : ICultMeshLookupSource
{
private readonly CultMeshVerseDescriptor _descriptor;
public SignedRouteSource(CultMeshVerseDescriptor descriptor) => _descriptor = descriptor;
public string SourceId => "signed-odin";
public Task<IReadOnlyList<CultMeshDiscoveryObservation>> LookupAsync(
CultMeshDiscoveryQuery query,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<CultMeshDiscoveryObservation>>(new[]
{
new CultMeshDiscoveryObservation(
_descriptor, SourceId, DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow.AddMinutes(1), CultMeshDiscoveryTrust.Signed)
});
}
private sealed class EchoConnector : ICultMeshTransportConnector
{
public string ConnectorId => "credential-free-echo";
public int Priority => 0;
public bool CanConnect(CultMeshTransportCandidate candidate) => true;
public Task<ICultNetSchemaClient> ConnectAsync(
CultMeshTransportCandidate candidate,
CultMeshProtocolId protocol,
CancellationToken cancellationToken = default) =>
Task.FromResult<ICultNetSchemaClient>(new EchoClient());
}
private sealed class EchoClient : ICultNetSchemaClient
{
private Action<CultMeshSessionAcceptedMessage>? _accepted;
public bool Connected => true;
public void Connect(string host, int port) { }
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage
{
if (message is not CultMeshSessionOpenMessage request) return;
_accepted?.Invoke(new CultMeshSessionAcceptedMessage
{
MessageId = request.MessageId,
Accepted = true,
VerseId = request.VerseId,
AuthorityRuntimeId = request.AuthorityRuntimeId,
ProtocolId = request.ProtocolId,
RouteGeneration = request.RouteGeneration,
ClientNonce = request.ClientNonce,
ProviderKeyId = "provider-live",
ProviderSignature = string.Empty
});
}
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
{
if (typeof(T) == typeof(CultMeshSessionAcceptedMessage))
_accepted = response => callback((T)(object)response);
}
public void Dispose() { }
}
private sealed class SigningConnector : ICultMeshTransportConnector
{
private readonly CultMeshSessionProofSigner _signer;
public SigningConnector(CultMeshSessionProofSigner signer) => _signer = signer;
public List<string> Attempts { get; } = new();
public string ConnectorId => "signed-provider";
public int Priority => 0;
public bool CanConnect(CultMeshTransportCandidate candidate) => true;
public Task<ICultNetSchemaClient> ConnectAsync(
CultMeshTransportCandidate candidate,
CultMeshProtocolId protocol,
CancellationToken cancellationToken = default)
{
Attempts.Add(candidate.Endpoint);
return Task.FromResult<ICultNetSchemaClient>(new SigningClient(_signer));
}
}
private sealed class SigningClient : ICultNetSchemaClient
{
private readonly CultMeshSessionProofSigner _signer;
private Action<CultMeshSessionAcceptedMessage>? _accepted;
public SigningClient(CultMeshSessionProofSigner signer) => _signer = signer;
public bool Connected => true;
public void Connect(string host, int port) { }
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage
{
if (message is not CultMeshSessionOpenMessage request) return;
_accepted?.Invoke(new CultMeshSessionAcceptedMessage
{
MessageId = request.MessageId,
Accepted = true,
VerseId = request.VerseId,
AuthorityRuntimeId = request.AuthorityRuntimeId,
ProtocolId = request.ProtocolId,
RouteGeneration = request.RouteGeneration,
ClientNonce = request.ClientNonce,
ProviderKeyId = _signer.ProviderKeyId,
ProviderSignature = _signer.Sign(request)
});
}
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
{
if (typeof(T) == typeof(CultMeshSessionAcceptedMessage))
_accepted = response => callback((T)(object)response);
}
public void Dispose() { }
}
private sealed class FixedContentHandler : HttpMessageHandler
{
private readonly byte[] _payload;
public FixedContentHandler(byte[] payload) => _payload = payload;
public Uri? RequestUri { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestUri = request.RequestUri;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(_payload)
});
}
}
private sealed class FixedClock : ICultMeshClock
{
public FixedClock(DateTimeOffset now) => UtcNow = now;
public DateTimeOffset UtcNow { get; }
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) =>
Task.Delay(delay, cancellationToken);
}
}

View file

@ -26,7 +26,7 @@ public sealed class CultMeshBodySessionTests
var client = new LoopbackClient(server);
var connector = new LoopbackConnector(client);
using var discovery = new CultMeshDiscoveryService(new[] { new RouteSource() });
using var sessions = new CultMeshSessionManager(discovery, new[] { connector });
using var sessions = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessions);
var provider = new CultMeshSessionBodyProvider(
"aetheria.daemon", sessions, new CultMeshSessionTarget("aetheria", "aetheria.daemon"));
@ -53,6 +53,13 @@ public sealed class CultMeshBodySessionTests
RuntimeId = "cultmesh-body-rudp-test", Socket = socket, MaxFragmentBytes = 1024,
MaxPendingReliablePackets = 8192
});
var endpoint = $"rudp://127.0.0.1:{server.LocalEndPoint.Port}";
using var identityServer = new CultMeshSessionIdentityServer(
server,
"aetheria.daemon",
new[] { "aetheria" },
new[] { CultMeshProtocols.Bodies.Value },
new[] { "aetheria.daemon@" + endpoint });
using var bodyServer = new CultMeshBodyServer(server, store);
using var pumpCancellation = new CancellationTokenSource();
var pump = Task.Run(async () =>
@ -64,10 +71,9 @@ public sealed class CultMeshBodySessionTests
if (progress.TransportItemsConsumed == 0) await Task.Delay(1, pumpCancellation.Token);
}
});
var endpoint = $"rudp://127.0.0.1:{server.LocalEndPoint.Port}";
using var discovery = new CultMeshDiscoveryService(new[] { new RouteSource(endpoint) });
using var sessions = new CultMeshSessionManager(discovery,
new ICultMeshTransportConnector[] { new CultMeshSchemaTransportConnector() });
new ICultMeshTransportConnector[] { new CultMeshSchemaTransportConnector() }, CultMeshTestTrust.LocalSessions);
var provider = new CultMeshSessionBodyProvider("aetheria.daemon", sessions,
new CultMeshSessionTarget("aetheria", "aetheria.daemon"),
new CultMeshSessionBodyProviderOptions { ResponseTimeout = TimeSpan.FromSeconds(10) });
@ -136,7 +142,7 @@ public sealed class CultMeshBodySessionTests
private sealed class RouteSource : ICultMeshLookupSource
{
private readonly string _endpoint;
public RouteSource(string endpoint = "rudp://body.test:3076") => _endpoint = endpoint;
public RouteSource(string endpoint = "rudp://127.0.0.13:3076") => _endpoint = endpoint;
public string SourceId => "test";
public Task<IReadOnlyList<CultMeshDiscoveryObservation>> LookupAsync(CultMeshDiscoveryQuery query,
CancellationToken cancellationToken = default)
@ -179,7 +185,7 @@ public sealed class CultMeshBodySessionTests
((Func<T, ICultNetSchemaServerPeer, Task>)_handlers[typeof(T)])(message, peer);
}
private sealed class LoopbackClient : ICultNetSchemaClient
private sealed class LoopbackClient : ICultNetSchemaClient, ICultMeshVerifiedSchemaClient
{
private readonly LoopbackServer _server;
private readonly Dictionary<Type, List<Delegate>> _handlers = new();
@ -208,6 +214,8 @@ public sealed class CultMeshBodySessionTests
foreach (var value in values.ToArray()) ((Action<T>)value)(message);
}
public void Dispose() { }
public bool IsVerifiedFor(string verseId, string authorityRuntimeId, string protocolId, string routeGeneration) =>
verseId == "aetheria" && authorityRuntimeId == "aetheria.daemon";
}
private sealed class LoopbackPeer : ICultNetSchemaServerPeer

View file

@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using NUnit.Framework;
@ -318,7 +319,9 @@ public sealed class CultMeshBodyTransportTests
public void BodyDemand_SelectsSharedMemoryLocallyAndNetworkRemotelyWithoutDuplicateFallbackWork()
{
using var tracker = new CultMeshBodyDemandTracker();
tracker.Plan("eve:entity-soa:aetheria.daemon:pilot").Generation.Should().Be(0);
tracker.Observe(Demand("unity-local", "local", sameMachine: true));
tracker.Plan("eve:entity-soa:aetheria.daemon:pilot").Generation.Should().Be(1);
tracker.Observe(Demand("unity-remote", "remote", sameMachine: false));
var mixed = tracker.Plan("eve:entity-soa:aetheria.daemon:pilot");
@ -335,8 +338,55 @@ public sealed class CultMeshBodyTransportTests
tracker.Observe(Demand("unity-remote", "remote", sameMachine: false, active: false));
var localOnly = tracker.Plan("eve:entity-soa:aetheria.daemon:pilot");
localOnly.Generation.Should().Be(3);
localOnly.RequiresSharedMemory.Should().BeTrue();
localOnly.RequiresNetwork.Should().BeFalse();
tracker.Observe(Demand("unity-local", "local", sameMachine: true, active: false));
tracker.Plan("eve:entity-soa:aetheria.daemon:pilot").Generation.Should().Be(4);
tracker.Observe(Demand("unity-local", "local", sameMachine: true));
tracker.Plan("eve:entity-soa:aetheria.daemon:pilot").Generation.Should().Be(5);
}
[Test]
public async Task BodyDemand_GenerationGuardLinearizesCommitAgainstWithdrawal()
{
const string bodyId = "eve:entity-soa:aetheria.daemon:pilot";
using var tracker = new CultMeshBodyDemandTracker();
tracker.Observe(Demand("unity-local", "local", sameMachine: true));
var activeGeneration = tracker.Plan(bodyId).Generation;
using var commitEntered = new ManualResetEventSlim();
using var releaseCommit = new ManualResetEventSlim();
var committed = false;
var commit = Task.Run(() => tracker.TryExecuteAtGeneration(
bodyId,
activeGeneration,
() =>
{
commitEntered.Set();
releaseCommit.Wait();
committed = true;
}));
commitEntered.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue();
var withdrawal = Task.Run(() =>
tracker.Observe(Demand("unity-local", "local", sameMachine: true, active: false)));
await Task.Delay(50);
withdrawal.IsCompleted.Should().BeFalse(
"a withdrawal must serialize after an already-linearized synchronous commit");
releaseCommit.Set();
(await commit).Should().BeTrue();
await withdrawal;
committed.Should().BeTrue();
tracker.Plan(bodyId).Generation.Should().BeGreaterThan(activeGeneration);
var staleCallbackRan = false;
tracker.TryExecuteAtGeneration(bodyId, activeGeneration, () => staleCallbackRan = true)
.Should().BeFalse();
staleCallbackRan.Should().BeFalse(
"work prepared for a withdrawn demand generation must be discarded before commit");
}
[Test]

View file

@ -40,6 +40,7 @@ public sealed class CultMeshClientTests
return new RendezvousClient();
}
},
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector }
});
@ -48,7 +49,7 @@ public sealed class CultMeshClientTests
first.Should().BeSameAs(second);
first.Target.Should().Be(Target);
first.State.Path!.Endpoint.Should().Be("rudp://aetheria:3081");
first.State.Path!.Endpoint.Should().Be("rudp://127.0.0.1:3081");
discoveryClients.Should().Be(1);
connector.ConnectCount.Should().Be(1);
}
@ -61,6 +62,7 @@ public sealed class CultMeshClientTests
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector }
});
@ -82,6 +84,7 @@ public sealed class CultMeshClientTests
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector }
});
@ -103,6 +106,7 @@ public sealed class CultMeshClientTests
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector }
});
using var lease = await mesh.LeaseDocumentAsync<ClientTestDocument>(Target, "surface:pilot");
@ -125,6 +129,7 @@ public sealed class CultMeshClientTests
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector }
});
@ -148,6 +153,7 @@ public sealed class CultMeshClientTests
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector },
SubscriptionResponseTimeout = TimeSpan.FromMilliseconds(20)
});
@ -168,6 +174,7 @@ public sealed class CultMeshClientTests
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector }
});
@ -187,6 +194,7 @@ public sealed class CultMeshClientTests
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector }
});
@ -211,6 +219,7 @@ public sealed class CultMeshClientTests
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector }
});
@ -237,6 +246,7 @@ public sealed class CultMeshClientTests
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector }
});
@ -262,6 +272,7 @@ public sealed class CultMeshClientTests
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector }
});
var request = new ClientTestDocument { Id = "command:1", Text = "move" };
@ -288,6 +299,7 @@ public sealed class CultMeshClientTests
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector }
});
@ -303,7 +315,7 @@ public sealed class CultMeshClientTests
result.MessageId.Should().Be("pilot-command-1");
result.Status.Should().Be("accepted");
result.SourceRuntimeId.Should().Be("provider-daemon");
result.SourceRuntimeId.Should().Be("aetheria-daemon");
result.Value.ReceiptId.Should().Be("receipt:pilot-command-1");
result.Value.Count.Should().Be(2);
connector.Clients.Should().ContainSingle();
@ -319,6 +331,7 @@ public sealed class CultMeshClientTests
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector },
OperationResponseTimeout = TimeSpan.FromSeconds(2)
});
@ -348,6 +361,7 @@ public sealed class CultMeshClientTests
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector },
OperationResponseTimeout = TimeSpan.FromSeconds(5)
});
@ -370,6 +384,32 @@ public sealed class CultMeshClientTests
connector.Clients[0].Operations.Should().ContainSingle();
}
[Test]
public async Task InvokeOperation_RejectsReceiptFromAnotherRuntime()
{
var connector = new DocumentConnector { ResponseSourceRuntimeId = "intruder-daemon" };
using var mesh = new CultMeshClient(new CultMeshClientOptions
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector }
});
Func<Task> invoke = async () => 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-wrong-source");
var failure = await invoke.Should().ThrowAsync<CultMeshSessionException>();
failure.Which.Failure.Reason.Should().Be(CultMeshSessionFailureReason.Authority);
}
[Test]
public async Task Dispose_TerminatesDocumentWaitingForInitialSnapshot()
{
@ -378,6 +418,7 @@ public sealed class CultMeshClientTests
{
RendezvousEndpoints = new[] { "rudp://odin:3076" },
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
Sessions = CultMeshTestTrust.LocalSessions,
Connectors = new[] { connector }
});
var opening = mesh.LeaseDocumentAsync<ClientTestDocument>(Target, "surface:pilot");
@ -407,7 +448,7 @@ public sealed class CultMeshClientTests
"Aetheria",
CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
new[] { "rudp://aetheria:3081" },
new[] { "rudp://127.0.0.1:3081" },
new[] { "aetheria-daemon" }).ToMessage()
}
};
@ -438,13 +479,15 @@ public sealed class CultMeshClientTests
}
}
private sealed class ConnectedClient : ICultNetSchemaClient
private sealed class ConnectedClient : ICultNetSchemaClient, ICultMeshVerifiedSchemaClient
{
public bool Connected => true;
public void Connect(string host, int port) { }
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage { }
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage { }
public void Dispose() { }
public bool IsVerifiedFor(string verseId, string authorityRuntimeId, string protocolId, string routeGeneration) =>
verseId == "aetheria" && authorityRuntimeId == "aetheria-daemon";
}
private sealed class DocumentConnector : ICultMeshTransportConnector
@ -454,6 +497,7 @@ public sealed class CultMeshClientTests
public bool SuppressFirstSubscriptionSnapshot { get; set; }
public bool RejectOperations { get; set; }
public bool FailFirstOperationBeforeResponse { get; set; }
public string ResponseSourceRuntimeId { get; set; } = "aetheria-daemon";
public string ConnectorId => "document-test";
public int Priority => 0;
public int SubscribeCount => Clients.Sum(client => client.SubscribeCount);
@ -469,13 +513,14 @@ public sealed class CultMeshClientTests
? int.MaxValue
: SuppressFirstSubscriptionSnapshot && Clients.Count == 0 ? 1 : 0,
rejectOperations: RejectOperations,
failOperationBeforeResponse: FailFirstOperationBeforeResponse && Clients.Count == 0);
failOperationBeforeResponse: FailFirstOperationBeforeResponse && Clients.Count == 0,
responseSourceRuntimeId: ResponseSourceRuntimeId);
Clients.Add(client);
return Task.FromResult<ICultNetSchemaClient>(client);
}
}
private sealed class DocumentClient : ICultNetSchemaClient, ICultNetSchemaClientHealth
private sealed class DocumentClient : ICultNetSchemaClient, ICultNetSchemaClientHealth, ICultMeshVerifiedSchemaClient
{
private readonly List<Action<CultNetSnapshotResponseRawMessage>> _snapshots = new();
private readonly List<Action<CultNetOperationResponseMessage>> _operationResponses = new();
@ -484,17 +529,20 @@ public sealed class CultMeshClientTests
private readonly TaskCompletionSource<Exception> _failure = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly bool _rejectOperations;
private bool _failOperationBeforeResponse;
private readonly string _responseSourceRuntimeId;
private int _snapshotsToSuppress;
public DocumentClient(
string text,
int snapshotsToSuppress = 0,
bool rejectOperations = false,
bool failOperationBeforeResponse = false)
bool failOperationBeforeResponse = false,
string responseSourceRuntimeId = "aetheria-daemon")
{
_text = text;
_snapshotsToSuppress = snapshotsToSuppress;
_rejectOperations = rejectOperations;
_failOperationBeforeResponse = failOperationBeforeResponse;
_responseSourceRuntimeId = responseSourceRuntimeId;
var cache = CultMesh.CreateCultCacheDocumentRegistry(typeof(ClientTestDocument));
_documents = CultMesh.CreateCultNetDocumentRegistry(new[] { typeof(ClientTestDocument) }, cache);
}
@ -514,6 +562,7 @@ public sealed class CultMeshClientTests
}
if (message is CultNetOperationRequestMessage operation)
{
operation.TargetRuntimeId.Should().Be("aetheria-daemon");
Operations.Add(operation);
if (_failOperationBeforeResponse)
{
@ -539,7 +588,7 @@ public sealed class CultMeshClientTests
failure,
CultNetSchemaMessageSerialization.Options)),
Diagnostics = new[] { failure.Message },
SourceRuntimeId = "provider-daemon"
SourceRuntimeId = _responseSourceRuntimeId
};
foreach (var handler in _operationResponses.ToArray()) handler(rejected);
return;
@ -562,7 +611,7 @@ public sealed class CultMeshClientTests
Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(
receipt,
CultNetSchemaMessageSerialization.Options)),
SourceRuntimeId = "provider-daemon"
SourceRuntimeId = _responseSourceRuntimeId
};
foreach (var handler in _operationResponses.ToArray()) handler(operationResponse);
return;
@ -596,6 +645,8 @@ public sealed class CultMeshClientTests
}
public void Dispose() { }
public void Fail(Exception error) => _failure.TrySetResult(error);
public bool IsVerifiedFor(string verseId, string authorityRuntimeId, string protocolId, string routeGeneration) =>
verseId == "aetheria" && authorityRuntimeId == "aetheria-daemon";
}
private static async Task WaitUntilAsync(Func<bool> condition)

View file

@ -17,6 +17,7 @@ namespace GameCult.Mesh.Tests;
[TestFixture]
public sealed class CultMeshContentSessionTests
{
private const string RouteGeneration = "content-test-route-1";
private string _directory = null!;
[SetUp]
@ -41,14 +42,20 @@ public sealed class CultMeshContentSessionTests
StringComparer.Ordinal);
using var contentServer = new CultMeshTcpContentServer(
new TcpListener(IPAddress.Loopback, 0),
hash => providerChunks.TryGetValue(hash, out var chunk) ? chunk : null);
hash => providerChunks.TryGetValue(hash, out var chunk) ? chunk : null,
new CultMeshTcpContentServerOptions
{
VerseId = "aetheria",
AuthorityRuntimeId = "aetheria.daemon",
RouteGeneration = RouteGeneration
});
var endpoint = $"{CultMeshTcpContentTransportConnector.Scheme}://127.0.0.1:{contentServer.LocalEndPoint.Port}";
var connector = new CultMeshTcpContentTransportConnector();
using var discovery = new CultMeshDiscoveryService(new[] { new RouteSource(endpoint) });
using var sessions = new CultMeshSessionManager(
discovery,
Array.Empty<ICultMeshTransportConnector>(),
new ICultMeshContentTransportConnector[] { connector });
new ICultMeshContentTransportConnector[] { connector }, CultMeshTestTrust.LocalSessions);
var provider = new CultMeshSessionContentProvider(
"aetheria.daemon",
sessions,
@ -73,6 +80,34 @@ public sealed class CultMeshContentSessionTests
"all chunk requests borrow one identity-first content session");
}
[Test]
public async Task TcpContentSessionRejectsEndpointServingAnotherAuthority()
{
using var content = new CultCache(CultMesh.CreateCultCacheDocumentRegistry(
typeof(CultMeshCdnArtifactManifest), typeof(CultMeshCdnArtifactChunk)));
using var contentServer = new CultMeshTcpContentServer(
new TcpListener(IPAddress.Loopback, 0),
content,
new CultMeshTcpContentServerOptions
{
VerseId = "aetheria",
AuthorityRuntimeId = "intruder.daemon",
RouteGeneration = RouteGeneration
});
var endpoint = $"{CultMeshTcpContentTransportConnector.Scheme}://127.0.0.1:{contentServer.LocalEndPoint.Port}";
using var discovery = new CultMeshDiscoveryService(new[] { new RouteSource(endpoint) });
using var sessions = new CultMeshSessionManager(
discovery,
Array.Empty<ICultMeshTransportConnector>(),
new ICultMeshContentTransportConnector[] { new CultMeshTcpContentTransportConnector() }, CultMeshTestTrust.LocalSessions);
Func<Task> connect = async () => await sessions.ConnectContentAsync(
new CultMeshSessionTarget("aetheria", "aetheria.daemon"));
var error = await connect.Should().ThrowAsync<CultMeshSessionException>();
error.Which.Failure.Reason.Should().Be(CultMeshSessionFailureReason.Authority);
}
[Test]
public async Task RudpContentSessionCarriesConcurrentChunkWindowWithoutPerFragmentSchedulerDelay()
{
@ -91,6 +126,12 @@ public sealed class CultMeshContentSessionTests
MaxPendingReliablePackets = 8192
});
using var contentServer = new CultMeshLegacyRudpContentServer(wireServer, providerCache);
using var identityServer = new CultMeshSessionIdentityServer(
wireServer,
"aetheria.daemon",
new[] { "aetheria" },
new[] { CultMeshProtocols.Content.Value },
new[] { RouteGeneration });
using var pumpCancellation = new CancellationTokenSource();
var pump = Task.Run(async () =>
{
@ -110,7 +151,7 @@ public sealed class CultMeshContentSessionTests
using var sessions = new CultMeshSessionManager(
discovery,
Array.Empty<ICultMeshTransportConnector>(),
new ICultMeshContentTransportConnector[] { connector });
new ICultMeshContentTransportConnector[] { connector }, CultMeshTestTrust.LocalSessions);
var provider = new CultMeshSessionContentProvider(
"aetheria.daemon",
sessions,
@ -179,7 +220,7 @@ public sealed class CultMeshContentSessionTests
{
private readonly string _endpoint;
public RouteSource(string endpoint = "rudp://content.test:3076") => _endpoint = endpoint;
public RouteSource(string endpoint = "rudp://127.0.0.14:3076") => _endpoint = endpoint;
public string SourceId => "test-rendezvous";
@ -196,8 +237,14 @@ public sealed class CultMeshContentSessionTests
"Aetheria",
CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v1", "rules"),
new[] { _endpoint },
new[] { "aetheria.daemon" }),
authorityRoutes: new[]
{
new CultMeshAuthorityRoute(
"aetheria.daemon",
_endpoint,
new[] { CultMeshProtocols.Content.Value },
generation: RouteGeneration)
}),
SourceId,
now,
now.AddMinutes(1),
@ -241,7 +288,7 @@ public sealed class CultMeshContentSessionTests
((Func<TMessage, ICultNetSchemaServerPeer, Task>)_handlers[typeof(TMessage)])(message, peer);
}
private sealed class LoopbackSchemaClient : ICultNetSchemaClient
private sealed class LoopbackSchemaClient : ICultNetSchemaClient, ICultMeshVerifiedSchemaClient
{
private readonly LoopbackSchemaServer _server;
private readonly Dictionary<Type, List<Delegate>> _handlers = new();
@ -270,6 +317,8 @@ public sealed class CultMeshContentSessionTests
handlers.Add(callback);
}
public void Dispose() { }
public bool IsVerifiedFor(string verseId, string authorityRuntimeId, string protocolId, string routeGeneration) =>
verseId == "aetheria" && authorityRuntimeId == "aetheria.daemon";
public void Emit<T>(T message) where T : ICultNetSchemaMessage
{
if (!_handlers.TryGetValue(typeof(T), out var handlers)) return;

View file

@ -295,7 +295,7 @@ public sealed class CultMeshDiscoveryServiceTests
CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
new[] { endpoint },
new[] { "odin:aetheria", "odin:games", "aetheria" }),
new[] { "aetheria" }),
sourceId,
clock.UtcNow,
clock.UtcNow + ttl,

View file

@ -28,22 +28,22 @@ public sealed class CultMeshSessionManagerTests
connected = candidate;
return Task.FromResult<ICultNetSchemaClient>(new FakeSchemaClient());
});
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock });
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock));
await manager.ConnectAsync(Target, CultMeshProtocols.Documents);
connected.Should().NotBeNull();
connected!.Endpoint.Should().Be("rudp://aetheria:3076");
connected!.Endpoint.Should().Be("rudp://127.0.0.1:3076");
}
[Test]
public async Task Connect_ReusesInflightAndEstablishedSession()
{
var clock = new ManualSessionClock();
using var discovery = Discovery(clock, () => new[] { "rudp://direct:3076" });
using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.2:3076" });
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var connector = new FakeConnector(async (_, _) => { await release.Task; return new FakeSchemaClient(); });
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock });
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock));
var target = Target;
var first = manager.ConnectAsync(target, CultMeshProtocols.Documents);
@ -61,29 +61,104 @@ public sealed class CultMeshSessionManagerTests
public async Task Connect_RacesPathsAndDisposesSuccessfulLoser()
{
var clock = new ManualSessionClock();
using var discovery = Discovery(clock, () => new[] { "rudp://slow:3076", "rudp://fast:3076" });
using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.3:3076", "rudp://127.0.0.4:3076" });
var slowRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var slowClient = new FakeSchemaClient();
var fastClient = new FakeSchemaClient();
var connector = new FakeConnector(async (candidate, _) =>
{
if (candidate.Endpoint.Contains("slow")) { await slowRelease.Task; return slowClient; }
if (candidate.Endpoint.Contains("127.0.0.3")) { await slowRelease.Task; return slowClient; }
return fastClient;
});
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock });
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock));
var session = await manager.ConnectAsync(Target, CultMeshProtocols.Documents);
session.State.Path!.Endpoint.Should().Be("rudp://fast:3076");
session.State.Path!.Endpoint.Should().Be("rudp://127.0.0.4:3076");
slowRelease.TrySetResult();
await WaitUntilAsync(() => slowClient.DisposeCount == 1);
fastClient.DisposeCount.Should().Be(0);
}
[Test]
public async Task Connect_NeverAttemptsRouteBoundToAnotherAuthority()
{
var clock = new ManualSessionClock();
var descriptor = new CultMeshVerseDescriptor(
"aetheria",
"Aetheria",
CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
authorityRoutes: new[]
{
new CultMeshAuthorityRoute("decoy-daemon", "rudp://127.0.0.5:3076"),
new CultMeshAuthorityRoute("aetheria-daemon", "rudp://127.0.0.6:3076")
});
using var discovery = new CultMeshDiscoveryService(
new[] { new StaticRouteSource(clock, descriptor) },
new CultMeshDiscoveryServiceOptions { Clock = clock });
var attempts = new List<string>();
var connector = new FakeConnector((candidate, _) =>
{
attempts.Add(candidate.Endpoint);
return Task.FromResult<ICultNetSchemaClient>(new FakeSchemaClient());
});
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessions);
var session = await manager.ConnectAsync(Target, CultMeshProtocols.Documents);
session.State.Path!.Endpoint.Should().Be("rudp://127.0.0.6:3076");
attempts.Should().Equal("rudp://127.0.0.6:3076");
}
[Test]
public void Connect_RejectsRouteWhoseNativeIdentityDoesNotMatchTarget()
{
var clock = new ManualSessionClock();
using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.5:3076" });
var connector = new FakeConnector((_, _) =>
Task.FromResult<ICultNetSchemaClient>(new NativeMismatchClient()));
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessions);
var error = Assert.ThrowsAsync<CultMeshSessionException>(() =>
manager.ConnectAsync(Target, CultMeshProtocols.Documents));
error!.Failure.Reason.Should().Be(CultMeshSessionFailureReason.Authority);
}
[Test]
public void Connect_RejectsPortableHandshakeFromWrongRuntime()
{
var clock = new ManualSessionClock();
using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.5:3076" });
var connector = new FakeConnector((_, _) =>
Task.FromResult<ICultNetSchemaClient>(new HandshakeSchemaClient("decoy-daemon")));
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessions);
var error = Assert.ThrowsAsync<CultMeshSessionException>(() =>
manager.ConnectAsync(Target, CultMeshProtocols.Documents));
error!.Failure.Reason.Should().Be(CultMeshSessionFailureReason.Authority);
}
[Test]
public void Descriptor_RejectsAmbiguousLegacyMultiAuthorityRoutes()
{
var action = () => new CultMeshVerseDescriptor(
"aetheria",
"Aetheria",
CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
discoveryEndpoints: new[] { "rudp://127.0.0.7:3076" },
authorityRuntimeIds: new[] { "authority-a", "authority-b" });
action.Should().Throw<InvalidOperationException>().WithMessage("*ambiguous*");
}
[Test]
public async Task Connect_CallerCancellationDoesNotCancelSharedAttempt()
{
var clock = new ManualSessionClock();
using var discovery = Discovery(clock, () => new[] { "rudp://direct:3076" });
using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.2:3076" });
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var connector = new FakeConnector(async (_, token) =>
{
@ -91,7 +166,7 @@ public sealed class CultMeshSessionManagerTests
await release.Task;
return new FakeSchemaClient();
});
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock });
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock));
using var cancellation = new CancellationTokenSource();
var target = Target;
@ -110,7 +185,7 @@ public sealed class CultMeshSessionManagerTests
public async Task BackgroundFailureMigratesSameSessionAcrossEndpointRotation()
{
var clock = new ManualSessionClock();
var route = "rudp://first:3076";
var route = "rudp://127.0.0.8:3076";
using var discovery = Discovery(clock, () => new[] { route }, TimeSpan.FromSeconds(5));
var clients = new List<FakeSchemaClient>();
var connector = new FakeConnector((_, _) =>
@ -119,7 +194,7 @@ public sealed class CultMeshSessionManagerTests
clients.Add(client);
return Task.FromResult<ICultNetSchemaClient>(client);
});
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock });
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock));
var target = Target;
var first = await manager.ConnectAsync(target, CultMeshProtocols.Documents);
var states = new List<CultMeshSessionStatus>();
@ -129,14 +204,14 @@ public sealed class CultMeshSessionManagerTests
schemaLease.OnCultNet<CultNetErrorMessage>(_ => deliveries++);
clients[0].Emit(new CultNetErrorMessage { Error = "before rotation" });
route = "rudp://second:3076";
route = "rudp://127.0.0.9:3076";
clients[0].Fail(new IOException("partition"));
await WaitUntilAsync(() => first.State.Status == CultMeshSessionStatus.Online && connector.ConnectCount == 2);
var second = await manager.ConnectAsync(target, CultMeshProtocols.Documents);
clients[1].Emit(new CultNetErrorMessage { Error = "after rotation" });
second.Should().BeSameAs(first);
second.State.Path!.Endpoint.Should().Be("rudp://second:3076");
second.State.Path!.Endpoint.Should().Be("rudp://127.0.0.9:3076");
states.Should().ContainInOrder(CultMeshSessionStatus.Reconnecting, CultMeshSessionStatus.Online);
connector.ConnectCount.Should().Be(2);
clients[0].DisposeCount.Should().Be(1);
@ -163,8 +238,8 @@ public sealed class CultMeshSessionManagerTests
public void Connect_ReportsUnsupportedPathPrecisely()
{
var clock = new ManualSessionClock();
using var discovery = Discovery(clock, () => new[] { "quic://unimplemented:443" });
using var manager = new CultMeshSessionManager(discovery, Array.Empty<ICultMeshTransportConnector>(), new CultMeshSessionManagerOptions { Clock = clock });
using var discovery = Discovery(clock, () => new[] { "quic://127.0.0.10:443" });
using var manager = new CultMeshSessionManager(discovery, Array.Empty<ICultMeshTransportConnector>(), CultMeshTestTrust.LocalSessionsWithClock(clock));
var error = Assert.ThrowsAsync<CultMeshSessionException>(() =>
manager.ConnectAsync(Target, CultMeshProtocols.Documents));
@ -178,10 +253,10 @@ public sealed class CultMeshSessionManagerTests
public async Task SnapshotSessionsBorrowOneManagedChannelAndDisposeRegistrationsIndependently()
{
var clock = new ManualSessionClock();
using var discovery = Discovery(clock, () => new[] { "rudp://direct:3076" });
using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.2:3076" });
var client = new RespondingSchemaClient();
var connector = new FakeConnector((_, _) => Task.FromResult<ICultNetSchemaClient>(client));
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock });
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock));
var target = Target;
using var first = await CultMeshSnapshotSession.ConnectAsync(manager, target, new CultMeshSnapshotRequestOptions());
using var second = await CultMeshSnapshotSession.ConnectAsync(manager, target, new CultMeshSnapshotRequestOptions());
@ -199,10 +274,10 @@ public sealed class CultMeshSessionManagerTests
public async Task PeerExchangeBorrowsManagedProtocolSessionAcrossRequests()
{
var clock = new ManualSessionClock();
using var discovery = Discovery(clock, () => new[] { "rudp://direct:3076" });
using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.2:3076" });
var client = new PeerExchangeSchemaClient();
var connector = new FakeConnector((_, _) => Task.FromResult<ICultNetSchemaClient>(client));
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock });
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock));
var exchange = new CultMeshPeerExchangeClient(new CultMeshPeerExchangeClientOptions { Sessions = manager, Clock = clock });
var target = Target;
@ -219,10 +294,10 @@ public sealed class CultMeshSessionManagerTests
public async Task SchemaClientLeasesDisposeHandlersWithoutClosingSession()
{
var clock = new ManualSessionClock();
using var discovery = Discovery(clock, () => new[] { "rudp://direct:3076" });
using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.2:3076" });
var client = new LeaseSchemaClient();
var connector = new FakeConnector((_, _) => Task.FromResult<ICultNetSchemaClient>(client));
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock });
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock));
var session = await manager.ConnectAsync(Target, CultMeshProtocols.Subscriptions);
using var first = session.OpenSchemaClient();
using var second = session.OpenSchemaClient();
@ -288,8 +363,8 @@ public sealed class CultMeshSessionManagerTests
var expires = _clock.UtcNow.AddMinutes(1);
return Task.FromResult<IReadOnlyList<CultMeshDiscoveryObservation>>(new[]
{
Observation("norn", "rudp://norn:3076", expires),
Observation("aetheria", "rudp://aetheria:3076", expires)
Observation("norn", "rudp://127.0.0.11:3076", expires),
Observation("aetheria", "rudp://127.0.0.1:3076", expires)
});
}
@ -308,6 +383,30 @@ public sealed class CultMeshSessionManagerTests
CultMeshDiscoveryTrust.Signed);
}
private sealed class StaticRouteSource : ICultMeshLookupSource
{
private readonly ManualSessionClock _clock;
private readonly CultMeshVerseDescriptor _descriptor;
public StaticRouteSource(ManualSessionClock clock, CultMeshVerseDescriptor descriptor)
{
_clock = clock;
_descriptor = descriptor;
}
public string SourceId => "odin";
public Task<IReadOnlyList<CultMeshDiscoveryObservation>> LookupAsync(
CultMeshDiscoveryQuery query,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<CultMeshDiscoveryObservation>>(new[]
{
new CultMeshDiscoveryObservation(
_descriptor,
SourceId,
_clock.UtcNow,
_clock.UtcNow.AddMinutes(1),
CultMeshDiscoveryTrust.Signed)
});
}
private sealed class FakeConnector : ICultMeshTransportConnector
{
private readonly Func<CultMeshTransportCandidate, CancellationToken, Task<ICultNetSchemaClient>> _connect;
@ -324,7 +423,7 @@ public sealed class CultMeshSessionManagerTests
}
}
private sealed class FakeSchemaClient : ICultNetSchemaClient, ICultNetSchemaClientHealth
private sealed class FakeSchemaClient : ICultNetSchemaClient, ICultNetSchemaClientHealth, ICultMeshVerifiedSchemaClient
{
private readonly TaskCompletionSource<Exception> _failure = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly List<Action<CultNetErrorMessage>> _handlers = new();
@ -340,6 +439,8 @@ public sealed class CultMeshSessionManagerTests
_handlers.Add(message => callback((T)(object)message));
}
public void Dispose() => Interlocked.Increment(ref _disposeCount);
public bool IsVerifiedFor(string verseId, string authorityRuntimeId, string protocolId, string routeGeneration) =>
verseId == "aetheria" && authorityRuntimeId == "aetheria-daemon";
public void Fail(Exception error) => _failure.TrySetResult(error);
public void Emit(CultNetErrorMessage message) { foreach (var handler in _handlers.ToArray()) handler(message); }
}
@ -360,7 +461,46 @@ public sealed class CultMeshSessionManagerTests
public void Dispose() { }
}
private sealed class RespondingSchemaClient : ICultNetSchemaClient
private sealed class NativeMismatchClient : ICultNetSchemaClient, ICultMeshVerifiedSchemaClient
{
public bool Connected => true;
public void Connect(string host, int port) { }
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage { }
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage { }
public bool IsVerifiedFor(string verseId, string authorityRuntimeId, string protocolId, string routeGeneration) => false;
public void Dispose() { }
}
private sealed class HandshakeSchemaClient : ICultNetSchemaClient
{
private readonly string _actualRuntimeId;
private readonly List<Action<CultMeshSessionAcceptedMessage>> _handlers = new();
public HandshakeSchemaClient(string actualRuntimeId) => _actualRuntimeId = actualRuntimeId;
public bool Connected => true;
public void Connect(string host, int port) { }
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage
{
if (message is not CultMeshSessionOpenMessage request) return;
var response = new CultMeshSessionAcceptedMessage
{
MessageId = request.MessageId,
Accepted = true,
VerseId = request.VerseId,
AuthorityRuntimeId = _actualRuntimeId,
ProtocolId = request.ProtocolId,
RouteGeneration = request.RouteGeneration
};
foreach (var handler in _handlers.ToArray()) handler(response);
}
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
{
if (typeof(T) == typeof(CultMeshSessionAcceptedMessage))
_handlers.Add(message => callback((T)(object)message));
}
public void Dispose() { }
}
private sealed class RespondingSchemaClient : ICultNetSchemaClient, ICultMeshVerifiedSchemaClient
{
private readonly List<Action<CultNetSnapshotResponseRawMessage>> _handlers = new();
private int _disposeCount;
@ -385,9 +525,11 @@ public sealed class CultMeshSessionManagerTests
_handlers.Add(message => callback((T)(object)message));
}
public void Dispose() => Interlocked.Increment(ref _disposeCount);
public bool IsVerifiedFor(string verseId, string authorityRuntimeId, string protocolId, string routeGeneration) =>
verseId == "aetheria" && authorityRuntimeId == "aetheria-daemon";
}
private sealed class PeerExchangeSchemaClient : ICultNetSchemaClient
private sealed class PeerExchangeSchemaClient : ICultNetSchemaClient, ICultMeshVerifiedSchemaClient
{
private readonly List<Action<CultMeshPeerExchangeResponseMessage>> _handlers = new();
public bool Connected => true;
@ -402,7 +544,7 @@ public sealed class CultMeshSessionManagerTests
MessageId = request.MessageId,
Peers = new[]
{
new CultMeshPeerCard("peer-a", request.VerseId, new[] { "rudp://peer-a:3076" }).ToMessage()
new CultMeshPeerCard("peer-a", request.VerseId, new[] { "rudp://127.0.0.12:3076" }).ToMessage()
}
};
foreach (var handler in _handlers.ToArray()) handler(response);
@ -413,9 +555,11 @@ public sealed class CultMeshSessionManagerTests
_handlers.Add(message => callback((T)(object)message));
}
public void Dispose() { }
public bool IsVerifiedFor(string verseId, string authorityRuntimeId, string protocolId, string routeGeneration) =>
verseId == "aetheria" && authorityRuntimeId == "aetheria-daemon";
}
private sealed class LeaseSchemaClient : ICultNetSchemaClient
private sealed class LeaseSchemaClient : ICultNetSchemaClient, ICultMeshVerifiedSchemaClient
{
private readonly List<Action<CultNetErrorMessage>> _handlers = new();
private int _disposeCount;
@ -430,6 +574,8 @@ public sealed class CultMeshSessionManagerTests
}
public void Emit(CultNetErrorMessage message) { foreach (var handler in _handlers.ToArray()) handler(message); }
public void Dispose() => Interlocked.Increment(ref _disposeCount);
public bool IsVerifiedFor(string verseId, string authorityRuntimeId, string protocolId, string routeGeneration) =>
verseId == "aetheria" && authorityRuntimeId == "aetheria-daemon";
}
private sealed class ManualSessionClock : ICultMeshClock

View file

@ -0,0 +1,18 @@
namespace GameCult.Mesh.Tests;
internal static class CultMeshTestTrust
{
internal static CultMeshAuthorityTrustPolicy LocalPolicy { get; } =
new(CultMeshAuthorityTrustMode.LocalDevelopment);
internal static CultMeshSessionManagerOptions LocalSessions => new()
{
Trust = LocalPolicy
};
internal static CultMeshSessionManagerOptions LocalSessionsWithClock(ICultMeshClock clock) => new()
{
Clock = clock,
Trust = LocalPolicy
};
}

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Threading;
@ -15,13 +16,22 @@ namespace GameCult.Mesh.Tests;
[TestFixture]
public sealed class CultMeshTransportModularityTests
{
private const string RouteGeneration = "transport-modularity-route-1";
[Test]
public async Task SchemaSessionPrefersTcpTierWithoutTouchingLegacyDatagrams()
{
using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0));
using var identity = new CultMeshSessionIdentityServer(
server,
"aetheria.daemon",
new[] { "aetheria" },
new[] { CultMeshProtocols.Documents.Value },
new[] { RouteGeneration });
server.OnCultNet<CultNetHelloMessage>((message, peer) =>
{
peer.SendCultNet(new CultNetErrorMessage { Error = "tcp:" + message.RuntimeId });
identity.TryGetSourceRuntimeId(peer, out var sourceRuntimeId).Should().BeTrue(
"application ingress must only trust a runtime identity bound by the session-open handshake");
peer.SendCultNet(new CultNetErrorMessage { Error = $"tcp:{message.RuntimeId}:{sourceRuntimeId}" });
return Task.CompletedTask;
});
var tcpEndpoint = $"cultnet+tcp://127.0.0.1:{server.LocalEndPoint.Port}";
@ -36,7 +46,7 @@ public sealed class CultMeshTransportModularityTests
{
legacy,
new CultMeshTcpSchemaTransportConnector()
});
}, CultMeshTestTrust.LocalSessions);
var session = await sessions.ConnectAsync(
new CultMeshSessionTarget("aetheria", "aetheria.daemon"),
@ -47,7 +57,7 @@ public sealed class CultMeshTransportModularityTests
var received = await response.Task.WaitAsync(TimeSpan.FromSeconds(5));
session.State.Path!.Endpoint.Should().Be(tcpEndpoint);
received.Error.Should().Be("tcp:eve-unity");
received.Error.Should().Be("tcp:eve-unity:cultmesh-client");
legacy.ConnectCount.Should().Be(0,
"a lower transport tier is fallback, not a race against the preferred TCP tier");
}
@ -61,7 +71,15 @@ public sealed class CultMeshTransportModularityTests
using var content = new CultCache(CultMesh.CreateCultCacheDocumentRegistry(
typeof(CultMeshCdnArtifactManifest), typeof(CultMeshCdnArtifactChunk)));
await CultMeshCdn.PublishAsync(content, artifact);
using var server = new CultMeshTcpContentServer(new TcpListener(IPAddress.Loopback, 0), content);
using var server = new CultMeshTcpContentServer(
new TcpListener(IPAddress.Loopback, 0),
content,
new CultMeshTcpContentServerOptions
{
VerseId = "aetheria",
AuthorityRuntimeId = "aetheria.daemon",
RouteGeneration = RouteGeneration
});
var tcpEndpoint = $"{CultMeshTcpContentTransportConnector.Scheme}://127.0.0.1:{server.LocalEndPoint.Port}";
var legacy = new RejectingLegacyContentConnector();
using var discovery = new CultMeshDiscoveryService(new[]
@ -75,7 +93,7 @@ public sealed class CultMeshTransportModularityTests
{
legacy,
new CultMeshTcpContentTransportConnector()
});
}, CultMeshTestTrust.LocalSessions);
var session = await sessions.ConnectContentAsync(new CultMeshSessionTarget("aetheria", "aetheria.daemon"));
using var destination = new MemoryStream();
@ -95,7 +113,7 @@ public sealed class CultMeshTransportModularityTests
});
using var sessions = new CultMeshSessionManager(
discovery,
Array.Empty<ICultMeshTransportConnector>());
Array.Empty<ICultMeshTransportConnector>(), CultMeshTestTrust.LocalSessions);
Func<Task> connect = async () => await sessions.ConnectContentAsync(
new CultMeshSessionTarget("aetheria", "aetheria.daemon"));
@ -116,7 +134,7 @@ public sealed class CultMeshTransportModularityTests
discovery,
Array.Empty<ICultMeshTransportConnector>(),
Array.Empty<ICultMeshContentTransportConnector>(),
new ICultMeshRealtimeTransportConnector[] { connector });
new ICultMeshRealtimeTransportConnector[] { connector }, CultMeshTestTrust.LocalSessions);
var session = await sessions.ConnectRealtimeAsync(
new CultMeshSessionTarget("aetheria", "aetheria.daemon"));
var sent = new CultMeshRealtimeFrame
@ -147,7 +165,7 @@ public sealed class CultMeshTransportModularityTests
});
using var sessions = new CultMeshSessionManager(
discovery,
Array.Empty<ICultMeshTransportConnector>());
Array.Empty<ICultMeshTransportConnector>(), CultMeshTestTrust.LocalSessions);
Func<Task> connect = async () => await sessions.ConnectRealtimeAsync(
new CultMeshSessionTarget("aetheria", "aetheria.daemon"));
@ -156,6 +174,26 @@ public sealed class CultMeshTransportModularityTests
.WithMessage("*No realtime state connectors are configured*");
}
[Test]
public async Task RealtimeSessionRejectsConnectedTransportWithoutAuthorityProof()
{
using var discovery = new CultMeshDiscoveryService(new[]
{
new RouteSource("cultmesh-state+quic://127.0.0.1:3076")
});
using var sessions = new CultMeshSessionManager(
discovery,
Array.Empty<ICultMeshTransportConnector>(),
Array.Empty<ICultMeshContentTransportConnector>(),
new ICultMeshRealtimeTransportConnector[] { new UnverifiedRealtimeConnector() }, CultMeshTestTrust.LocalSessions);
Func<Task> connect = async () => await sessions.ConnectRealtimeAsync(
new CultMeshSessionTarget("aetheria", "aetheria.daemon"));
var error = await connect.Should().ThrowAsync<CultMeshSessionException>();
error.Which.Failure.Reason.Should().Be(CultMeshSessionFailureReason.Authority);
}
private sealed class RouteSource : ICultMeshLookupSource
{
private readonly string[] _endpoints;
@ -176,8 +214,11 @@ public sealed class CultMeshTransportModularityTests
"Aetheria",
CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v1", "rules"),
_endpoints,
new[] { "aetheria.daemon" }),
authorityRoutes: _endpoints.Select(endpoint => new CultMeshAuthorityRoute(
"aetheria.daemon",
endpoint,
protocolIds: null,
generation: RouteGeneration))),
SourceId,
now,
now.AddMinutes(1),
@ -214,7 +255,7 @@ public sealed class CultMeshTransportModularityTests
candidate.Endpoint.StartsWith("rudp://", StringComparison.OrdinalIgnoreCase);
public Task<ICultMeshContentTransport> ConnectAsync(
CultMeshTransportCandidate candidate,
CultMeshEndpointId endpointId,
CultMeshSessionTarget target,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _connectCount);
@ -232,24 +273,70 @@ public sealed class CultMeshTransportModularityTests
candidate.Endpoint.StartsWith("cultmesh-state+quic://", StringComparison.OrdinalIgnoreCase);
public Task<ICultMeshRealtimeTransport> ConnectAsync(
CultMeshTransportCandidate candidate,
CultMeshEndpointId endpointId,
CultMeshSessionTarget target,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _connectCount);
return Task.FromResult<ICultMeshRealtimeTransport>(new LoopbackRealtimeTransport(candidate.Endpoint));
return Task.FromResult<ICultMeshRealtimeTransport>(
new LoopbackRealtimeTransport(candidate.Endpoint, target, candidate.Generation));
}
}
private sealed class LoopbackRealtimeTransport : ICultMeshRealtimeTransport
private sealed class UnverifiedRealtimeConnector : ICultMeshRealtimeTransportConnector
{
public string ConnectorId => "unverified-realtime";
public int Priority => 0;
public bool CanConnect(CultMeshTransportCandidate candidate) => true;
public Task<ICultMeshRealtimeTransport> ConnectAsync(
CultMeshTransportCandidate candidate,
CultMeshSessionTarget target,
CancellationToken cancellationToken = default) =>
Task.FromResult<ICultMeshRealtimeTransport>(new UnverifiedRealtimeTransport(candidate.Endpoint));
}
private sealed class UnverifiedRealtimeTransport : ICultMeshRealtimeTransport
{
public UnverifiedRealtimeTransport(string endpoint) => Endpoint = endpoint;
public string TransportId => "unverified-realtime";
public string Endpoint { get; }
public Task SendAsync(CultMeshRealtimeFrame frame, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public Task<CultMeshRealtimeFrame> ReceiveAsync(CancellationToken cancellationToken = default) =>
Task.FromException<CultMeshRealtimeFrame>(new InvalidOperationException());
public void Dispose() { }
}
private sealed class LoopbackRealtimeTransport : ICultMeshRealtimeTransport, ICultMeshVerifiedTransport
{
private readonly Queue<CultMeshRealtimeFrame> _frames = new();
private readonly SemaphoreSlim _available = new(0);
private bool _disposed;
public LoopbackRealtimeTransport(string endpoint) => Endpoint = endpoint;
private readonly CultMeshSessionTarget _target;
private readonly string _routeGeneration;
public LoopbackRealtimeTransport(
string endpoint,
CultMeshSessionTarget target,
string routeGeneration)
{
Endpoint = endpoint;
_target = target;
_routeGeneration = routeGeneration;
}
public string TransportId => "test-quic-state";
public string Endpoint { get; }
public bool IsVerifiedFor(
string verseId,
string authorityRuntimeId,
string protocolId,
string routeGeneration) =>
string.Equals(_target.VerseId, verseId, StringComparison.Ordinal) &&
string.Equals(_target.AuthorityRuntimeId, authorityRuntimeId, StringComparison.Ordinal) &&
string.Equals(CultMeshProtocols.RealtimeState.Value, protocolId, StringComparison.Ordinal) &&
string.Equals(_routeGeneration, routeGeneration, StringComparison.Ordinal);
public Task SendAsync(CultMeshRealtimeFrame frame, CancellationToken cancellationToken = default)
{
if (_disposed) throw new ObjectDisposedException(nameof(LoopbackRealtimeTransport));

View file

@ -15,7 +15,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="NUnit" Version="4.*" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="FluentAssertions" Version="8.9.0" />

View file

@ -77,6 +77,41 @@ namespace GameCult.Networking.Tests
Is.EqualTo(4));
}
[Test]
public async Task RejectsRequestAddressedToAnotherRuntimeBeforeApplicationHandler()
{
var transport = new FakeServer();
var peer = new FakePeer();
var calls = 0;
using var operations = new CultNetOperationServer(transport, "sample.provider")
.Register<AddRequest, AddReceipt>(
"sample.counter",
"sample.counter.add",
"sample.add.v1",
"sample.add_receipt.v1",
context =>
{
calls++;
return Task.FromResult(new AddReceipt { Count = context.Value.Amount });
});
var request = Request(new AddRequest { Amount = 3 });
request.TargetRuntimeId = "other.provider";
await transport.DispatchAsync(request, peer);
Assert.That(calls, Is.Zero);
var response = peer.Messages.OfType<CultNetOperationResponseMessage>().Single();
var failure = MessagePackSerializer.Deserialize<CultNetOperationFailure>(
Convert.FromBase64String(response.Payload),
CultNetSchemaMessageSerialization.Options);
Assert.Multiple(() =>
{
Assert.That(response.Status, Is.EqualTo("denied"));
Assert.That(failure.Code, Is.EqualTo("target-runtime-mismatch"));
Assert.That(response.SourceRuntimeId, Is.EqualTo("sample.provider"));
});
}
[Test]
public async Task RejectsWrongSchemaBeforeApplicationHandler()
{
@ -157,7 +192,8 @@ namespace GameCult.Networking.Tests
Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(
value,
CultNetSchemaMessageSerialization.Options)),
SourceRuntimeId = "sample.browser"
SourceRuntimeId = "sample.browser",
TargetRuntimeId = "sample.provider"
};
[MessagePackObject]

View file

@ -1514,6 +1514,179 @@ namespace GameCult.Networking.Tests
Assert.That(update.Document!.RecordKey, Is.EqualTo(recordKey));
}
[Test]
public async Task DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords()
{
var cache = new CultCache();
var database = new CultNetDatabase(cache);
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-authorized-subscription-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(
server,
database,
authorizeRequest: (request, _) => request.ConsumerRuntimeId == "allowed-runtime",
authorizeRecord: (_, _, recordKey, _) => recordKey.StartsWith("tests:public:", StringComparison.Ordinal),
projectRecord: (request, _, record) =>
{
record.SourceRuntimeId = "projection:" + request.ConsumerRuntimeId;
return record;
});
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
await database.PutAsync(new CultRecordKey("tests:public:initial"), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "public-initial"
});
await database.PutAsync(new CultRecordKey("tests:private:initial"), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "private-initial"
});
using var client = CultNetSchemaClients.CreateRudp("database-authorized-subscription-client");
var subscribed = new TaskCompletionSource<CultNetSnapshotResponseRawMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
var changed = new TaskCompletionSource<CultNetDatabaseChangeRawMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
client.OnCultNet<CultNetSnapshotResponseRawMessage>(message => subscribed.TrySetResult(message));
client.OnCultNet<CultNetDatabaseChangeRawMessage>(message => changed.TrySetResult(message));
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
client.SendCultNet(new CultNetDatabaseSubscribeMessage
{
MessageId = "subscribe-authorized",
SubscriptionId = "authorized",
ConsumerRuntimeId = "allowed-runtime",
IncludeSnapshot = true
});
var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2));
Assert.That(snapshot.Documents.Select(document => document.RecordKey),
Is.EqualTo(new[] { "tests:public:initial" }));
Assert.That(snapshot.Documents.Single().SourceRuntimeId, Is.EqualTo("projection:allowed-runtime"));
await database.PutAsync(new CultRecordKey("tests:private:live"), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "private-live"
});
await database.PutAsync(new CultRecordKey("tests:public:live"), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "public-live"
});
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
cancellation.Cancel();
Assert.That(update.Document, Is.Not.Null);
Assert.That(update.Document!.RecordKey, Is.EqualTo("tests:public:live"));
Assert.That(update.Document.SourceRuntimeId, Is.EqualTo("projection:allowed-runtime"));
}
[Test]
public async Task DatabaseSubscriptionServer_ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges()
{
const string sourceRecordKey = "tests:authority:source";
const string bodyId = "tests:authority:body";
var authorized = true;
var projectionName = "one";
var sourceCache = new CultCache();
var sourceDatabase = new CultNetDatabase(sourceCache);
await sourceDatabase.PutAsync(new CultRecordKey(sourceRecordKey), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "initial"
});
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-reconciled-subscription-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(
server,
sourceDatabase,
authorizeRequest: (_, _) => authorized,
authorizeRecord: (_, _, _, _) => authorized,
projectRecord: (_, _, record) =>
{
record.RecordKey = $"tests:authority:projected:{projectionName}";
return record;
});
using var bodyDemand = new CultMeshBodyDemandTracker(subscriptions);
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
var targetCache = new CultCache();
var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(targetCache.Registry));
var transport = CultNetSchemaClients.CreateRudp("database-reconciled-subscription-client");
using var client = new CultNetDatabaseSubscriptionClient(transport, targetCache, targetDocuments);
var changes = new ConcurrentQueue<CultNetReplicatedDocumentChange>();
client.Changed += changes.Enqueue;
transport.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => transport.Connected, TimeSpan.FromSeconds(2));
await AwaitWithTimeout(
client.SubscribeAsync(
"authority",
recordKeys: [sourceRecordKey],
consumerRuntimeId: "authority-client",
bodyIds: [bodyId],
supportedBodyTransports: [CultMeshBodyTransportKind.SharedMemory.ToString()]),
TimeSpan.FromSeconds(2));
Assert.That(targetCache.Get(new CultRecordKey("tests:authority:projected:one")), Is.Not.Null);
Assert.That(bodyDemand.Plan(bodyId).HasConsumers, Is.True);
projectionName = "two";
subscriptions.Reconcile();
await WaitUntilAsync(
() => targetCache.Get(new CultRecordKey("tests:authority:projected:one")) == null &&
targetCache.Get(new CultRecordKey("tests:authority:projected:two")) != null,
TimeSpan.FromSeconds(2));
Assert.That(changes.Any(change => change.ChangeKind == "removed" &&
change.RecordKey == "tests:authority:projected:one"), Is.True);
Assert.That(changes.Any(change => change.ChangeKind == "added" &&
change.RecordKey == "tests:authority:projected:two"), Is.True);
authorized = false;
subscriptions.Reconcile();
await WaitUntilAsync(
() => targetCache.Get(new CultRecordKey("tests:authority:projected:two")) == null,
TimeSpan.FromSeconds(2));
Assert.That(bodyDemand.Plan(bodyId).HasConsumers, Is.False);
var changeCountAfterRevocation = changes.Count;
await sourceDatabase.PutAsync(new CultRecordKey(sourceRecordKey), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "must-not-escape"
});
await Task.Delay(100);
cancellation.Cancel();
Assert.That(changes.Count, Is.EqualTo(changeCountAfterRevocation));
Assert.That(targetCache.Get(new CultRecordKey("tests:authority:projected:two")), Is.Null);
}
[Test]
public async Task DatabaseSubscriptionServer_ProjectsBodyDemandAndWithdrawalFromExactSubscription()
{
@ -2753,6 +2926,60 @@ namespace GameCult.Networking.Tests
Assert.That(changes[0].Document!.Username, Is.EqualTo("Raw"));
}
[Test]
public async Task CultNetDatabase_RequiredTransaction_DefersRawPutPublicationUntilCommit()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
RequireTransactionsForAuthoritativeWrites = true
});
var key = new CultRecordKey("player:transactional-raw");
var message = registry.CreateRawDocumentPutMessage(
"put-transactional-raw",
new CultRecordHandle<PlayerData>(key),
new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "transactional@example.test",
PasswordHash = "hash",
Username = "Transactional"
});
var changes = new List<CultNetDatabaseChange<PlayerData>>();
using var subscription = database.WatchRecord<PlayerData>(key)
.Subscribe(change => changes.Add(change));
Assert.That(
async () => await database.ApplyPutAsync(message),
Throws.TypeOf<InvalidOperationException>());
var staged = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var transaction = database.ExecuteTransactionAsync(async () =>
{
await database.ApplyPutAsync(message);
staged.SetResult(true);
await release.Task;
});
await staged.Task;
Assert.That(cache.Get<PlayerData>(key), Is.Null);
Assert.That(changes, Is.Empty);
release.SetResult(true);
await transaction;
Assert.That(cache.Get<PlayerData>(key)?.Username, Is.EqualTo("Transactional"));
Assert.That(changes, Has.Count.EqualTo(1));
Assert.That(changes[0].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Added));
}
[Test]
public async Task CultNetDatabase_ApplyRawPut_ResolvesForeignSchemaIdFromPayload()
{
@ -3679,8 +3906,19 @@ namespace GameCult.Networking.Tests
"Aetheria",
CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v0", vanillaRules),
discoveryEndpoints: ["cultmesh://aetheria.example.test:3075"],
authorityRuntimeIds: ["gc-us-east", "gc-eu-west"]);
authorityRoutes:
[
new CultMeshAuthorityRoute(
"gc-us-east",
"cultmesh://us-east.aetheria.example.test:3075",
[CultMeshProtocols.Documents.Value],
generation: "east-1"),
new CultMeshAuthorityRoute(
"gc-eu-west",
"cultmesh://eu-west.aetheria.example.test:3075",
[CultMeshProtocols.Documents.Value],
generation: "west-1")
]);
var modded = new CultMeshVerseDescriptor(
"aetheria-skylands",
"Aetheria: Skylands",

View file

@ -201,7 +201,8 @@ static async Task<StateResult> MeasureCultMeshQuicStateAsync(int payloadBytes, i
[new CultMeshQuicRealtimeTransportConnector(new CultMeshQuicRealtimeConnectorOptions
{
ValidateProviderCertificate = (_, _, _, _) => true
})]);
})],
LocalDevelopmentSessions());
var session = await sessions.ConnectRealtimeAsync(
new CultMeshSessionTarget("parity", "service:parity.provider"));
var deadline = DateTime.UtcNow.AddSeconds(5);
@ -339,6 +340,12 @@ static async Task<Result> MeasureCultMeshRudpAsync(byte[] payload)
MaxPendingReliablePackets = 8192
});
using var contentServer = new CultMeshLegacyRudpContentServer(wireServer, providerCache);
using var identityServer = new CultMeshSessionIdentityServer(
wireServer,
"service:parity.provider",
["parity"],
[CultMeshProtocols.Content.Value],
["parity-route-1"]);
using var pumpCancellation = new CancellationTokenSource();
var pump = Task.Run(async () =>
{
@ -357,9 +364,10 @@ static async Task<Result> MeasureCultMeshRudpAsync(byte[] payload)
discovery,
Array.Empty<ICultMeshTransportConnector>(),
[new CultMeshLegacyRudpContentTransportConnector(
new CultMeshLegacyRudpContentTransportOptions { ResponseTimeout = TimeSpan.FromMinutes(2) })]);
new CultMeshLegacyRudpContentTransportOptions { ResponseTimeout = TimeSpan.FromMinutes(2) })],
LocalDevelopmentSessions());
var provider = new CultMeshSessionContentProvider(
"parity.provider", sessions, CultMeshEndpointId.Parse("service:parity.provider"));
"parity.provider", sessions, new CultMeshSessionTarget("parity", "service:parity.provider"));
var directory = Path.Combine(Path.GetTempPath(), "cultmesh-transport-parity", Guid.NewGuid().ToString("N"));
using var transferState = new CultCache(CultMesh.CreateCultCacheDocumentRegistry(
typeof(CultMeshContentTransferStateDocument)));
@ -392,15 +400,23 @@ static async Task<Result> MeasureCultMeshTcpAsync(byte[] payload)
await CultMeshCdn.PublishAsync(providerCache, artifact);
using var contentServer = new CultMeshTcpContentServer(
new TcpListener(IPAddress.Loopback, 0), providerCache);
new TcpListener(IPAddress.Loopback, 0),
providerCache,
new CultMeshTcpContentServerOptions
{
VerseId = "parity",
AuthorityRuntimeId = "service:parity.provider",
RouteGeneration = "parity-route-1"
});
var endpoint = $"{CultMeshTcpContentTransportConnector.Scheme}://127.0.0.1:{contentServer.LocalEndPoint.Port}";
using var discovery = new CultMeshDiscoveryService([new RouteSource(endpoint)]);
using var sessions = new CultMeshSessionManager(
discovery,
Array.Empty<ICultMeshTransportConnector>(),
[new CultMeshTcpContentTransportConnector()]);
[new CultMeshTcpContentTransportConnector()],
LocalDevelopmentSessions());
var provider = new CultMeshSessionContentProvider(
"parity.provider", sessions, CultMeshEndpointId.Parse("service:parity.provider"));
"parity.provider", sessions, new CultMeshSessionTarget("parity", "service:parity.provider"));
var directory = Path.Combine(Path.GetTempPath(), "cultmesh-transport-parity", Guid.NewGuid().ToString("N"));
using var transferState = new CultCache(CultMesh.CreateCultCacheDocumentRegistry(
typeof(CultMeshContentTransferStateDocument)));
@ -443,6 +459,11 @@ static X509Certificate2 CreateCertificate()
static string TempPath(string plane) =>
Path.Combine(Path.GetTempPath(), $"cultmesh-parity-{plane}-{Guid.NewGuid():N}.body");
static CultMeshSessionManagerOptions LocalDevelopmentSessions() => new()
{
Trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment)
};
static Socket BindUdpSocket()
{
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
@ -479,8 +500,14 @@ internal sealed class RouteSource(string endpoint) : ICultMeshLookupSource
new(
new CultMeshVerseDescriptor(
"parity", "Transport parity", CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v1", "parity"), [endpoint],
["service:parity.provider"]),
new CultMeshVerseCompatibility("cultmesh.v1", "parity"),
authorityRoutes:
[
new CultMeshAuthorityRoute(
"service:parity.provider",
endpoint,
generation: "parity-route-1")
]),
SourceId, now, now.AddMinutes(1), CultMeshDiscoveryTrust.Signed)
];
return Task.FromResult(observations);

View file

@ -14,6 +14,7 @@
"GameCult.Mesh.dll",
"GameCult.Mesh.Quic.Native.dll",
"GameCult.Networking.dll",
"GameCult.Networking.WebSockets.dll",
"Isopoh.Cryptography.Argon2.dll",
"Isopoh.Cryptography.Blake2b.dll",
"Isopoh.Cryptography.SecureArray.dll",

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 78372006a7d6494ca38f58a78eb535a8

View file

@ -1,7 +1,7 @@
{
"name": "org.gamecult.cultlib",
"displayName": "CultLib",
"version": "1.0.47",
"version": "1.0.56",
"unity": "2021.3",
"description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.",
"license": "MIT",