Bind CultMesh authority identity to physical routes
This commit is contained in:
parent
21163d83fa
commit
6c848bd6c0
45 changed files with 1671 additions and 194 deletions
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -846,11 +846,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/cultmesh-browser": {
|
"packages/cultmesh-browser": {
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@msgpack/msgpack": "^3.1.3",
|
"@msgpack/msgpack": "^3.1.3",
|
||||||
"cultnet-ts": "^0.1.1"
|
"cultnet-ts": "^0.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
|
@ -878,7 +878,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/cultnet-ts": {
|
"packages/cultnet-ts": {
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@msgpack/msgpack": "^3.1.3",
|
"@msgpack/msgpack": "^3.1.3",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
Eve surface's `providerId`, which identifies the UI provider inside the Verse.
|
||||||
The physical socket is a replaceable route, not either stable identity.
|
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
|
The browser handshake carries authentication through the host application's
|
||||||
ordinary secure cookie or equivalent upgrade credential. The C# host adapter
|
ordinary secure cookie or equivalent upgrade credential. The C# host adapter
|
||||||
rejects endpoints without an authorization predicate unless anonymous local
|
rejects endpoints without an authorization predicate unless anonymous local
|
||||||
development is explicitly enabled.
|
development is explicitly enabled.
|
||||||
|
|
||||||
Run `scripts/verify-eve-browser-network.mjs --eve-root <path-to-Eve>` for the
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "cultmesh-browser",
|
"name": "cultmesh-browser",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.",
|
"description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@msgpack/msgpack": "^3.1.3",
|
"@msgpack/msgpack": "^3.1.3",
|
||||||
"cultnet-ts": "^0.1.1"
|
"cultnet-ts": "^0.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import {
|
||||||
type CultNetSnapshotResponseRawMessage,
|
type CultNetSnapshotResponseRawMessage,
|
||||||
type CultMeshVerseCatalogRequestMessage,
|
type CultMeshVerseCatalogRequestMessage,
|
||||||
type CultMeshVerseCatalogResponseMessage,
|
type CultMeshVerseCatalogResponseMessage,
|
||||||
|
type CultMeshSessionAcceptedMessage,
|
||||||
|
type CultMeshSessionOpenMessage,
|
||||||
} from "cultnet-ts/contracts";
|
} from "cultnet-ts/contracts";
|
||||||
|
|
||||||
export interface CultMeshBrowserIdentity {
|
export interface CultMeshBrowserIdentity {
|
||||||
|
|
@ -21,7 +23,8 @@ export interface CultMeshBrowserIdentity {
|
||||||
|
|
||||||
export interface CultMeshBrowserRoute extends CultMeshBrowserIdentity {
|
export interface CultMeshBrowserRoute extends CultMeshBrowserIdentity {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
generation?: string;
|
protocolId?: string;
|
||||||
|
generation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CultMeshBrowserRendezvous {
|
export interface CultMeshBrowserRendezvous {
|
||||||
|
|
@ -173,6 +176,7 @@ export interface CultMeshOperationOptions {
|
||||||
payloadSchema: string;
|
payloadSchema: string;
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
idempotencyKey?: string;
|
idempotencyKey?: string;
|
||||||
|
/** @deprecated The connected authority is the operation target. A different value is rejected. */
|
||||||
targetRuntimeId?: string;
|
targetRuntimeId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -338,6 +342,11 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
||||||
requireText(options.serviceId, "serviceId");
|
requireText(options.serviceId, "serviceId");
|
||||||
requireText(options.operation, "operation");
|
requireText(options.operation, "operation");
|
||||||
requireText(options.payloadSchema, "payloadSchema");
|
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();
|
await this.ensureConnected();
|
||||||
const messageId = options.idempotencyKey ?? this.#options.createId();
|
const messageId = options.idempotencyKey ?? this.#options.createId();
|
||||||
requireText(messageId, "operation idempotencyKey");
|
requireText(messageId, "operation idempotencyKey");
|
||||||
|
|
@ -353,7 +362,7 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
||||||
payloadEncoding: "messagepack-base64",
|
payloadEncoding: "messagepack-base64",
|
||||||
payload: bytesToBase64(encode(options.payload)),
|
payload: bytesToBase64(encode(options.payload)),
|
||||||
sourceRuntimeId: this.runtimeId,
|
sourceRuntimeId: this.runtimeId,
|
||||||
...(options.targetRuntimeId ? { targetRuntimeId: options.targetRuntimeId } : {}),
|
targetRuntimeId: this.identity.authorityRuntimeId,
|
||||||
};
|
};
|
||||||
const response = new Promise<CultNetOperationResponseMessage>((resolve, reject) => {
|
const response = new Promise<CultNetOperationResponseMessage>((resolve, reject) => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
|
@ -489,13 +498,67 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
||||||
socket.binaryType = "arraybuffer";
|
socket.binaryType = "arraybuffer";
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let opened = false;
|
||||||
|
let settled = false;
|
||||||
|
const handshakeId = this.#options.createId();
|
||||||
|
const finish = (error?: Error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve();
|
||||||
|
};
|
||||||
const timer = setTimeout(
|
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,
|
this.#options.connectTimeoutMs,
|
||||||
);
|
);
|
||||||
socket.onopen = () => { clearTimeout(timer); resolve(); };
|
socket.onopen = () => {
|
||||||
socket.onerror = () => { clearTimeout(timer); reject(new Error(`CultMesh WebSocket could not open '${route.endpoint}'.`)); };
|
opened = true;
|
||||||
socket.onclose = () => { clearTimeout(timer); reject(new Error(`CultMesh WebSocket closed while opening '${route.endpoint}'.`)); };
|
try {
|
||||||
|
sendSocketMessage(socket, {
|
||||||
|
schemaVersion: "cultnet.hello.v0",
|
||||||
|
runtimeId: this.runtimeId,
|
||||||
|
runtimeKind: "browser",
|
||||||
|
supportedMessageVersions: [
|
||||||
|
"cultmesh.session_open.v1",
|
||||||
|
"cultmesh.session_accepted.v1",
|
||||||
|
"cultnet.database_subscribe.v0",
|
||||||
|
"cultnet.database_unsubscribe.v0",
|
||||||
|
"cultnet.database_change_raw.v0",
|
||||||
|
"cultnet.operation_request.v0",
|
||||||
|
"cultnet.operation_response.v0",
|
||||||
|
],
|
||||||
|
}, this.#options.maxFrameBytes);
|
||||||
|
const request: CultMeshSessionOpenMessage = {
|
||||||
|
schemaVersion: "cultmesh.session_open.v1",
|
||||||
|
messageId: handshakeId,
|
||||||
|
sourceRuntimeId: this.runtimeId,
|
||||||
|
verseId: this.identity.verseId,
|
||||||
|
authorityRuntimeId: this.identity.authorityRuntimeId,
|
||||||
|
protocolId: route.protocolId ?? "cultmesh.documents.v1",
|
||||||
|
routeGeneration: route.generation,
|
||||||
|
};
|
||||||
|
sendSocketMessage(socket, request, 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.v1" || message.messageId !== handshakeId) return;
|
||||||
|
finish(validateSessionAcceptance(message, this.identity, route));
|
||||||
|
}).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) {
|
} catch (error) {
|
||||||
socket.close(1000, "CultMesh route open failed");
|
socket.close(1000, "CultMesh route open failed");
|
||||||
|
|
@ -511,18 +574,6 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
||||||
socket.onclose = () => this.handleClosed(generation);
|
socket.onclose = () => this.handleClosed(generation);
|
||||||
this.setState("connected");
|
this.setState("connected");
|
||||||
try {
|
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)));
|
await Promise.all([...this.#leases.values()].map(lease => this.subscribe(lease)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.#socket === socket) this.#socket = undefined;
|
if (this.#socket === socket) this.#socket = undefined;
|
||||||
|
|
@ -576,6 +627,15 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
||||||
case "cultnet.operation_response.v0": {
|
case "cultnet.operation_response.v0": {
|
||||||
const pending = this.#pendingOperations.get(message.messageId);
|
const pending = this.#pendingOperations.get(message.messageId);
|
||||||
if (!pending) return;
|
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);
|
this.#pendingOperations.delete(message.messageId);
|
||||||
clearTimeout(pending.timer);
|
clearTimeout(pending.timer);
|
||||||
if (message.payloadSchema === cultNetOperationFailureSchema) {
|
if (message.payloadSchema === cultNetOperationFailureSchema) {
|
||||||
|
|
@ -733,33 +793,72 @@ function selectOdinRoute(
|
||||||
response: CultMeshVerseCatalogResponseMessage,
|
response: CultMeshVerseCatalogResponseMessage,
|
||||||
identity: CultMeshBrowserIdentity,
|
identity: CultMeshBrowserIdentity,
|
||||||
): CultMeshBrowserRoute {
|
): CultMeshBrowserRoute {
|
||||||
const matchingVerses = response.verses.filter(verse =>
|
const matchingVerses = response.verses.filter(verse => verse.verseId === identity.verseId);
|
||||||
verse.verseId === identity.verseId &&
|
|
||||||
verse.authorityRuntimeIds.includes(identity.authorityRuntimeId));
|
|
||||||
if (matchingVerses.length !== 1) {
|
if (matchingVerses.length !== 1) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Odin returned ${matchingVerses.length} routes for Verse '${identity.verseId}' authority runtime '${identity.authorityRuntimeId}'.`,
|
`Odin returned ${matchingVerses.length} routes for Verse '${identity.verseId}' authority runtime '${identity.authorityRuntimeId}'.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const verse = matchingVerses[0];
|
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(":"),
|
||||||
|
}));
|
||||||
|
const routes = boundRoutes.filter(route =>
|
||||||
|
route.authorityRuntimeId === identity.authorityRuntimeId &&
|
||||||
|
route.protocolIds.includes("cultmesh.documents.v1") &&
|
||||||
|
(() => {
|
||||||
try {
|
try {
|
||||||
requireWebSocketEndpoint(endpoint, "Odin provider route");
|
requireWebSocketEndpoint(route.endpoint, "Odin provider route");
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
})()).sort((left, right) => left.priority - right.priority || left.endpoint.localeCompare(right.endpoint));
|
||||||
if (endpoints.length === 0) {
|
if (routes.length === 0) {
|
||||||
throw new Error(`Odin advertised no browser-compatible route for authority runtime '${identity.authorityRuntimeId}'.`);
|
throw new Error(`Odin advertised no browser-compatible route for authority runtime '${identity.authorityRuntimeId}'.`);
|
||||||
}
|
}
|
||||||
|
const route = routes[0];
|
||||||
return {
|
return {
|
||||||
...identity,
|
...identity,
|
||||||
endpoint: endpoints[0],
|
endpoint: route.endpoint,
|
||||||
generation: [verse.compatibility.transportVersion, verse.compatibility.rulesHash, endpoints[0]].join(":"),
|
protocolId: "cultmesh.documents.v1",
|
||||||
|
generation: route.generation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateSessionAcceptance(
|
||||||
|
message: CultMeshSessionAcceptedMessage,
|
||||||
|
identity: CultMeshBrowserIdentity,
|
||||||
|
route: CultMeshBrowserRoute,
|
||||||
|
): 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 !== identity.verseId ||
|
||||||
|
message.authorityRuntimeId !== identity.authorityRuntimeId ||
|
||||||
|
message.protocolId !== protocolId ||
|
||||||
|
message.routeGeneration !== route.generation) {
|
||||||
|
return new Error(
|
||||||
|
`CultMesh route proved '${message.verseId}/${message.authorityRuntimeId}/${message.protocolId}/${message.routeGeneration}', ` +
|
||||||
|
`expected '${identity.verseId}/${identity.authorityRuntimeId}/${protocolId}/${route.generation}'.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function requireText(value: string, field: string): void {
|
function requireText(value: string, field: string): void {
|
||||||
if (!value || value.trim().length === 0) throw new Error(`CultMesh browser ${field} is required.`);
|
if (!value || value.trim().length === 0) throw new Error(`CultMesh browser ${field} is required.`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
type CultNetMessage,
|
type CultNetMessage,
|
||||||
type CultNetOperationRequestMessage,
|
type CultNetOperationRequestMessage,
|
||||||
type CultNetRawDocumentRecord,
|
type CultNetRawDocumentRecord,
|
||||||
|
type CultMeshSessionOpenMessage,
|
||||||
} from "cultnet-ts/contracts";
|
} from "cultnet-ts/contracts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -48,6 +49,46 @@ test("Odin rendezvous resolves stable authority-runtime identity and observes ro
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
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" }),
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof AggregateError &&
|
||||||
|
error.errors.some(inner => inner instanceof Error && /ambiguous legacy routes/.test(inner.message)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("browser client leases provider state, invokes an operation, and follows route replacement", async () => {
|
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 first = new FakeProvider("ws://127.0.0.1:4101/mesh", 4);
|
||||||
const second = new FakeProvider("ws://127.0.0.1:4102/mesh", 9);
|
const second = new FakeProvider("ws://127.0.0.1:4102/mesh", 9);
|
||||||
|
|
@ -117,18 +158,33 @@ test("browser client rejects wrong rendezvous identity and has no document write
|
||||||
assert.equal("setDocument" in CultMeshBrowserClient.prototype, false);
|
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",
|
||||||
|
rendezvous: { resolve: async () => ({ ...provider.route, authorityRuntimeId: "sample.counter-daemon" }) },
|
||||||
|
socketFactory: () => provider.open(),
|
||||||
|
}),
|
||||||
|
/proved .*intruder-runtime.*expected .*sample.counter-daemon/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("browser client bounds unanswered provider operations", async () => {
|
test("browser client bounds unanswered provider operations", async () => {
|
||||||
const route = {
|
const route = {
|
||||||
verseId: "sample.counter",
|
verseId: "sample.counter",
|
||||||
authorityRuntimeId: "sample.counter-daemon",
|
authorityRuntimeId: "sample.counter-daemon",
|
||||||
endpoint: "ws://127.0.0.1:4301/mesh",
|
endpoint: "ws://127.0.0.1:4301/mesh",
|
||||||
|
generation: "route-4301",
|
||||||
};
|
};
|
||||||
const client = await CultMeshBrowserClient.connect({
|
const client = await CultMeshBrowserClient.connect({
|
||||||
...route,
|
...route,
|
||||||
runtimeId: "browser-timeout-test",
|
runtimeId: "browser-timeout-test",
|
||||||
rendezvous: { resolve: async () => route },
|
rendezvous: { resolve: async () => route },
|
||||||
requestTimeoutMs: 5,
|
requestTimeoutMs: 5,
|
||||||
socketFactory: () => new FakeSocket(() => undefined, () => undefined),
|
socketFactory: () => handshakeOnlySocket(route),
|
||||||
});
|
});
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
client.invoke({
|
client.invoke({
|
||||||
|
|
@ -172,11 +228,39 @@ test("browser client rejects a correlated framework failure without waiting for
|
||||||
await client.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",
|
||||||
|
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 () => {
|
test("browser client rejects oversized outbound schema messages", async () => {
|
||||||
const route = {
|
const route = {
|
||||||
verseId: "sample.counter",
|
verseId: "sample.counter",
|
||||||
authorityRuntimeId: "sample.counter-daemon",
|
authorityRuntimeId: "sample.counter-daemon",
|
||||||
endpoint: "ws://127.0.0.1:4401/mesh",
|
endpoint: "ws://127.0.0.1:4401/mesh",
|
||||||
|
generation: "route-4401",
|
||||||
};
|
};
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
CultMeshBrowserClient.connect({
|
CultMeshBrowserClient.connect({
|
||||||
|
|
@ -220,8 +304,16 @@ class FakeProvider {
|
||||||
#count: number;
|
#count: number;
|
||||||
#subscriptions = new Map<FakeSocket, Map<string, CultNetDatabaseSubscribeMessage>>();
|
#subscriptions = new Map<FakeSocket, Map<string, CultNetDatabaseSubscribeMessage>>();
|
||||||
#rejectOperations: boolean;
|
#rejectOperations: boolean;
|
||||||
|
#runtimeId: string;
|
||||||
|
#responseRuntimeId: string;
|
||||||
|
|
||||||
constructor(endpoint: string, count: number, rejectOperations = false) {
|
constructor(
|
||||||
|
endpoint: string,
|
||||||
|
count: number,
|
||||||
|
rejectOperations = false,
|
||||||
|
runtimeId = "sample.counter-daemon",
|
||||||
|
responseRuntimeId = runtimeId,
|
||||||
|
) {
|
||||||
this.route = {
|
this.route = {
|
||||||
verseId: "sample.counter",
|
verseId: "sample.counter",
|
||||||
authorityRuntimeId: "sample.counter-daemon",
|
authorityRuntimeId: "sample.counter-daemon",
|
||||||
|
|
@ -230,6 +322,8 @@ class FakeProvider {
|
||||||
};
|
};
|
||||||
this.#count = count;
|
this.#count = count;
|
||||||
this.#rejectOperations = rejectOperations;
|
this.#rejectOperations = rejectOperations;
|
||||||
|
this.#runtimeId = runtimeId;
|
||||||
|
this.#responseRuntimeId = responseRuntimeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
get activeSubscriptionCount(): number {
|
get activeSubscriptionCount(): number {
|
||||||
|
|
@ -250,6 +344,9 @@ class FakeProvider {
|
||||||
private receive(socket: FakeSocket, wire: Uint8Array): void {
|
private receive(socket: FakeSocket, wire: Uint8Array): void {
|
||||||
const message = parseCultNetMessage(decode(wire));
|
const message = parseCultNetMessage(decode(wire));
|
||||||
switch (message.schemaVersion) {
|
switch (message.schemaVersion) {
|
||||||
|
case "cultmesh.session_open.v1":
|
||||||
|
this.acceptSession(socket, message);
|
||||||
|
return;
|
||||||
case "cultnet.database_subscribe.v0":
|
case "cultnet.database_subscribe.v0":
|
||||||
this.subscribe(socket, message);
|
this.subscribe(socket, message);
|
||||||
return;
|
return;
|
||||||
|
|
@ -264,6 +361,18 @@ class FakeProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private acceptSession(socket: FakeSocket, message: CultMeshSessionOpenMessage): void {
|
||||||
|
socket.deliver({
|
||||||
|
schemaVersion: "cultmesh.session_accepted.v1",
|
||||||
|
messageId: message.messageId,
|
||||||
|
accepted: true,
|
||||||
|
verseId: message.verseId,
|
||||||
|
authorityRuntimeId: this.#runtimeId,
|
||||||
|
protocolId: message.protocolId,
|
||||||
|
routeGeneration: message.routeGeneration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private subscribe(socket: FakeSocket, message: CultNetDatabaseSubscribeMessage): void {
|
private subscribe(socket: FakeSocket, message: CultNetDatabaseSubscribeMessage): void {
|
||||||
this.#subscriptions.get(socket)!.set(message.subscriptionId, message);
|
this.#subscriptions.get(socket)!.set(message.subscriptionId, message);
|
||||||
socket.deliver({
|
socket.deliver({
|
||||||
|
|
@ -274,6 +383,7 @@ class FakeProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
private invoke(socket: FakeSocket, message: CultNetOperationRequestMessage): void {
|
private invoke(socket: FakeSocket, message: CultNetOperationRequestMessage): void {
|
||||||
|
assert.equal(message.targetRuntimeId, this.#runtimeId);
|
||||||
if (this.#rejectOperations) {
|
if (this.#rejectOperations) {
|
||||||
socket.deliver({
|
socket.deliver({
|
||||||
schemaVersion: "cultnet.operation_response.v0",
|
schemaVersion: "cultnet.operation_response.v0",
|
||||||
|
|
@ -288,7 +398,7 @@ class FakeProvider {
|
||||||
message: "Expected payload schema 'sample.increment.v1'.",
|
message: "Expected payload schema 'sample.increment.v1'.",
|
||||||
})),
|
})),
|
||||||
diagnostics: ["Request schema did not match."],
|
diagnostics: ["Request schema did not match."],
|
||||||
sourceRuntimeId: "sample.counter-daemon",
|
sourceRuntimeId: this.#responseRuntimeId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -314,7 +424,7 @@ class FakeProvider {
|
||||||
payloadSchema: "sample.increment_receipt.v1",
|
payloadSchema: "sample.increment_receipt.v1",
|
||||||
payloadEncoding: "messagepack-base64",
|
payloadEncoding: "messagepack-base64",
|
||||||
payload: bytesToBase64(encode({ count: this.#count })),
|
payload: bytesToBase64(encode({ count: this.#count })),
|
||||||
sourceRuntimeId: "sample.counter-daemon",
|
sourceRuntimeId: this.#responseRuntimeId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,13 +438,15 @@ class FakeProvider {
|
||||||
storedAt: "2026-08-17T00:00:00Z",
|
storedAt: "2026-08-17T00:00:00Z",
|
||||||
payloadEncoding: "messagepack",
|
payloadEncoding: "messagepack",
|
||||||
payload: encode({ count: this.#count }),
|
payload: encode({ count: this.#count }),
|
||||||
sourceRuntimeId: "sample.counter-daemon",
|
sourceRuntimeId: this.#runtimeId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeOdin {
|
class FakeOdin {
|
||||||
providerEndpoint: string;
|
providerEndpoint: string;
|
||||||
|
decoyEndpoint: string | undefined;
|
||||||
|
omitAuthorityRoutes = false;
|
||||||
|
|
||||||
constructor(providerEndpoint: string) {
|
constructor(providerEndpoint: string) {
|
||||||
this.providerEndpoint = providerEndpoint;
|
this.providerEndpoint = providerEndpoint;
|
||||||
|
|
@ -359,7 +471,25 @@ class FakeOdin {
|
||||||
optionalPluginIds: [],
|
optionalPluginIds: [],
|
||||||
},
|
},
|
||||||
discoveryEndpoints: [this.providerEndpoint],
|
discoveryEndpoints: [this.providerEndpoint],
|
||||||
authorityRuntimeIds: ["sample.counter-daemon"],
|
authorityRuntimeIds: this.decoyEndpoint
|
||||||
|
? ["sample.counter-daemon", "sample.decoy-daemon"]
|
||||||
|
: ["sample.counter-daemon"],
|
||||||
|
...(!this.omitAuthorityRoutes ? { authorityRoutes: [
|
||||||
|
...(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);
|
}, () => undefined);
|
||||||
|
|
@ -367,6 +497,24 @@ class FakeOdin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handshakeOnlySocket(route: CultMeshBrowserRoute): FakeSocket {
|
||||||
|
let socket: FakeSocket;
|
||||||
|
socket = new FakeSocket(wire => {
|
||||||
|
const message = parseCultNetMessage(decode(wire));
|
||||||
|
if (message.schemaVersion !== "cultmesh.session_open.v1") return;
|
||||||
|
socket.deliver({
|
||||||
|
schemaVersion: "cultmesh.session_accepted.v1",
|
||||||
|
messageId: message.messageId,
|
||||||
|
accepted: true,
|
||||||
|
verseId: route.verseId,
|
||||||
|
authorityRuntimeId: route.authorityRuntimeId,
|
||||||
|
protocolId: message.protocolId,
|
||||||
|
routeGeneration: message.routeGeneration,
|
||||||
|
});
|
||||||
|
}, () => undefined);
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
class FakeSocket implements CultMeshBrowserSocket {
|
class FakeSocket implements CultMeshBrowserSocket {
|
||||||
binaryType: BinaryType = "arraybuffer";
|
binaryType: BinaryType = "arraybuffer";
|
||||||
readyState = 0;
|
readyState = 0;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"$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"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"schemaVersion": { "const": "cultmesh.session_accepted.v1" },
|
||||||
|
"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" },
|
||||||
|
"error": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"$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"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"schemaVersion": { "const": "cultmesh.session_open.v1" },
|
||||||
|
"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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,18 @@
|
||||||
"optionalPluginIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }
|
"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 }
|
||||||
|
}
|
||||||
|
},
|
||||||
"verse": {
|
"verse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["verseId", "displayName", "authorityModel", "compatibility", "discoveryEndpoints", "authorityRuntimeIds"],
|
"required": ["verseId", "displayName", "authorityModel", "compatibility", "discoveryEndpoints", "authorityRuntimeIds"],
|
||||||
|
|
@ -29,6 +41,7 @@
|
||||||
"compatibility": { "$ref": "#/$defs/compatibility" },
|
"compatibility": { "$ref": "#/$defs/compatibility" },
|
||||||
"discoveryEndpoints": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
|
"discoveryEndpoints": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
|
||||||
"authorityRuntimeIds": { "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 },
|
"parentVerseId": { "type": "string", "minLength": 1 },
|
||||||
"description": { "type": "string" }
|
"description": { "type": "string" }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "cultnet-ts",
|
"name": "cultnet-ts",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"description": "CultNet-style typed direct-pipe messaging and state replication for TypeScript runtimes.",
|
"description": "CultNet-style typed direct-pipe messaging and state replication for TypeScript runtimes.",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ import operationRequestSchema from "../contracts/cultnet.operation-request.schem
|
||||||
import operationResponseSchema from "../contracts/cultnet.operation-response.schema.json";
|
import operationResponseSchema from "../contracts/cultnet.operation-response.schema.json";
|
||||||
import verseCatalogRequestSchema from "../contracts/cultmesh.verse-catalog-request.schema.json";
|
import verseCatalogRequestSchema from "../contracts/cultmesh.verse-catalog-request.schema.json";
|
||||||
import verseCatalogResponseSchema from "../contracts/cultmesh.verse-catalog-response.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";
|
import ghostlightAgentStateSchema from "../contracts/ghostlight.agent-state.schema.json";
|
||||||
|
|
||||||
export type CultNetWireContract = "cultnet.schema.v0" | "gamecult.networking.v0";
|
export type CultNetWireContract = "cultnet.schema.v0" | "gamecult.networking.v0";
|
||||||
|
|
@ -59,7 +61,9 @@ export type CultNetSchemaVersion =
|
||||||
| "cultnet.operation_request.v0"
|
| "cultnet.operation_request.v0"
|
||||||
| "cultnet.operation_response.v0"
|
| "cultnet.operation_response.v0"
|
||||||
| "cultmesh.verse_catalog_request.v0"
|
| "cultmesh.verse_catalog_request.v0"
|
||||||
| "cultmesh.verse_catalog_response.v0";
|
| "cultmesh.verse_catalog_response.v0"
|
||||||
|
| "cultmesh.session_open.v1"
|
||||||
|
| "cultmesh.session_accepted.v1";
|
||||||
|
|
||||||
export type CultNetSchemaKind = "wire_message" | "document_payload" | "shared_contract";
|
export type CultNetSchemaKind = "wire_message" | "document_payload" | "shared_contract";
|
||||||
export type CultNetRawPayloadEncoding = "messagepack";
|
export type CultNetRawPayloadEncoding = "messagepack";
|
||||||
|
|
@ -350,10 +354,40 @@ export interface CultMeshVerseDescriptorMessage {
|
||||||
compatibility: CultMeshVerseCompatibilityMessage;
|
compatibility: CultMeshVerseCompatibilityMessage;
|
||||||
discoveryEndpoints: string[];
|
discoveryEndpoints: string[];
|
||||||
authorityRuntimeIds: string[];
|
authorityRuntimeIds: string[];
|
||||||
|
authorityRoutes?: CultMeshAuthorityRouteMessage[];
|
||||||
parentVerseId?: string;
|
parentVerseId?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CultMeshAuthorityRouteMessage {
|
||||||
|
authorityRuntimeId: string;
|
||||||
|
endpoint: string;
|
||||||
|
protocolIds: string[];
|
||||||
|
priority: number;
|
||||||
|
generation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CultMeshSessionOpenMessage {
|
||||||
|
schemaVersion: "cultmesh.session_open.v1";
|
||||||
|
messageId: string;
|
||||||
|
sourceRuntimeId: string;
|
||||||
|
verseId: string;
|
||||||
|
authorityRuntimeId: string;
|
||||||
|
protocolId: string;
|
||||||
|
routeGeneration: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CultMeshSessionAcceptedMessage {
|
||||||
|
schemaVersion: "cultmesh.session_accepted.v1";
|
||||||
|
messageId: string;
|
||||||
|
accepted: boolean;
|
||||||
|
verseId: string;
|
||||||
|
authorityRuntimeId: string;
|
||||||
|
protocolId: string;
|
||||||
|
routeGeneration: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CultMeshVerseCatalogRequestMessage {
|
export interface CultMeshVerseCatalogRequestMessage {
|
||||||
schemaVersion: "cultmesh.verse_catalog_request.v0";
|
schemaVersion: "cultmesh.verse_catalog_request.v0";
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
|
@ -618,7 +652,9 @@ export type CultNetMessage =
|
||||||
| CultNetOperationRequestMessage
|
| CultNetOperationRequestMessage
|
||||||
| CultNetOperationResponseMessage
|
| CultNetOperationResponseMessage
|
||||||
| CultMeshVerseCatalogRequestMessage
|
| CultMeshVerseCatalogRequestMessage
|
||||||
| CultMeshVerseCatalogResponseMessage;
|
| CultMeshVerseCatalogResponseMessage
|
||||||
|
| CultMeshSessionOpenMessage
|
||||||
|
| CultMeshSessionAcceptedMessage;
|
||||||
|
|
||||||
const ajv = new Ajv2020({
|
const ajv = new Ajv2020({
|
||||||
allErrors: true,
|
allErrors: true,
|
||||||
|
|
@ -651,6 +687,8 @@ const CULTNET_MESSAGE_SCHEMAS = [
|
||||||
operationResponseSchema,
|
operationResponseSchema,
|
||||||
verseCatalogRequestSchema,
|
verseCatalogRequestSchema,
|
||||||
verseCatalogResponseSchema,
|
verseCatalogResponseSchema,
|
||||||
|
sessionOpenSchema,
|
||||||
|
sessionAcceptedSchema,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const cultNetValidators = new Map<CultNetSchemaVersion, ValidateFunction>();
|
const cultNetValidators = new Map<CultNetSchemaVersion, ValidateFunction>();
|
||||||
|
|
@ -1198,6 +1236,9 @@ function normalizeCultNetOptionalNulls(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
case "cultmesh.session_accepted.v1":
|
||||||
|
stripNullProperties(candidate, ["error"]);
|
||||||
|
return;
|
||||||
case "cultnet.schema_catalog_request.v0":
|
case "cultnet.schema_catalog_request.v0":
|
||||||
stripNullProperties(candidate, ["schemaIds", "kinds"]);
|
stripNullProperties(candidate, ["schemaIds", "kinds"]);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -83,10 +83,29 @@ test("CultNet validates the canonical CultMesh Verse catalog messages", () => {
|
||||||
},
|
},
|
||||||
discoveryEndpoints: ["wss://provider.example/cultmesh"],
|
discoveryEndpoints: ["wss://provider.example/cultmesh"],
|
||||||
authorityRuntimeIds: ["sample.counter-provider"],
|
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.schemaVersion, "cultmesh.verse_catalog_response.v0");
|
||||||
assert.equal(response.verses[0].authorityRuntimeIds[0], "sample.counter-provider");
|
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.v1",
|
||||||
|
messageId: "session-1",
|
||||||
|
accepted: true,
|
||||||
|
verseId: "sample.counter",
|
||||||
|
authorityRuntimeId: "sample.counter-provider",
|
||||||
|
protocolId: "cultmesh.documents.v1",
|
||||||
|
routeGeneration: "provider-route-1",
|
||||||
|
});
|
||||||
|
assert.equal(session.schemaVersion, "cultmesh.session_accepted.v1");
|
||||||
|
|
||||||
assert.throws(() => parseCultNetMessage({
|
assert.throws(() => parseCultNetMessage({
|
||||||
...response,
|
...response,
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,30 @@ static async Task RunOdinAsync(Args arguments)
|
||||||
if (string.IsNullOrWhiteSpace(arguments.ProviderEndpoint))
|
if (string.IsNullOrWhiteSpace(arguments.ProviderEndpoint))
|
||||||
throw new ArgumentException("The Odin fixture requires --provider-endpoint.");
|
throw new ArgumentException("The Odin fixture requires --provider-endpoint.");
|
||||||
using var catalog = new CultMeshVerseCatalog();
|
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(
|
catalog.Upsert(new CultMeshVerseDescriptor(
|
||||||
arguments.VerseId,
|
arguments.VerseId,
|
||||||
arguments.VerseName,
|
arguments.VerseName,
|
||||||
CultMeshVerseAuthorityModel.OperatorCluster,
|
CultMeshVerseAuthorityModel.OperatorCluster,
|
||||||
new CultMeshVerseCompatibility(arguments.TransportVersion, arguments.RulesHash),
|
new CultMeshVerseCompatibility(arguments.TransportVersion, arguments.RulesHash),
|
||||||
discoveryEndpoints: [arguments.ProviderEndpoint],
|
authorityRoutes: routes,
|
||||||
authorityRuntimeIds: [arguments.AuthorityRuntimeId],
|
|
||||||
description: "Local executable Odin fixture for the browser/network onboarding witness."));
|
description: "Local executable Odin fixture for the browser/network onboarding witness."));
|
||||||
|
|
||||||
await using var schemaServer = new CultNetWebSocketSchemaServer();
|
await using var schemaServer = new CultNetWebSocketSchemaServer();
|
||||||
|
|
@ -97,6 +114,12 @@ static async Task RunProviderAsync(Args arguments)
|
||||||
await node.FlushAsync();
|
await node.FlushAsync();
|
||||||
|
|
||||||
await using var schemaServer = new CultNetWebSocketSchemaServer();
|
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 subscriptions = new CultNetDatabaseSubscriptionServer(schemaServer, node.Database);
|
||||||
using var operationGate = new SemaphoreSlim(1, 1);
|
using var operationGate = new SemaphoreSlim(1, 1);
|
||||||
using var operations = new CultNetOperationServer(schemaServer, arguments.AuthorityRuntimeId)
|
using var operations = new CultNetOperationServer(schemaServer, arguments.AuthorityRuntimeId)
|
||||||
|
|
@ -347,12 +370,16 @@ sealed class Args
|
||||||
public string StatePath { get; private init; } = "sample-counter.cc";
|
public string StatePath { get; private init; } = "sample-counter.cc";
|
||||||
public string OdinEndpoint { get; private init; } = "";
|
public string OdinEndpoint { get; private init; } = "";
|
||||||
public string ProviderEndpoint { 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 Token { get; private init; } = "sample-session";
|
||||||
public string VerseId { get; private init; } = "sample.counter";
|
public string VerseId { get; private init; } = "sample.counter";
|
||||||
public string VerseName { get; private init; } = "CultMesh browser counter";
|
public string VerseName { get; private init; } = "CultMesh browser counter";
|
||||||
public string AuthorityRuntimeId { get; private init; } = "sample.counter-daemon";
|
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 TransportVersion { get; private init; } = "cultmesh.v0";
|
||||||
public string RulesHash { get; private init; } = "sample-counter-v1";
|
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 ExpectedCount { get; private init; } = 2;
|
||||||
public int BenchmarkOperations { get; private init; } = 10_000;
|
public int BenchmarkOperations { get; private init; } = 10_000;
|
||||||
|
|
||||||
|
|
@ -369,12 +396,16 @@ sealed class Args
|
||||||
StatePath = named.GetValueOrDefault("--state", "sample-counter.cc"),
|
StatePath = named.GetValueOrDefault("--state", "sample-counter.cc"),
|
||||||
OdinEndpoint = named.GetValueOrDefault("--odin", ""),
|
OdinEndpoint = named.GetValueOrDefault("--odin", ""),
|
||||||
ProviderEndpoint = named.GetValueOrDefault("--provider-endpoint", ""),
|
ProviderEndpoint = named.GetValueOrDefault("--provider-endpoint", ""),
|
||||||
|
DecoyEndpoint = named.GetValueOrDefault("--decoy-endpoint", ""),
|
||||||
Token = named.GetValueOrDefault("--token", "sample-session"),
|
Token = named.GetValueOrDefault("--token", "sample-session"),
|
||||||
VerseId = named.GetValueOrDefault("--verse-id", "sample.counter"),
|
VerseId = named.GetValueOrDefault("--verse-id", "sample.counter"),
|
||||||
VerseName = named.GetValueOrDefault("--verse-name", "CultMesh browser counter"),
|
VerseName = named.GetValueOrDefault("--verse-name", "CultMesh browser counter"),
|
||||||
AuthorityRuntimeId = named.GetValueOrDefault("--authority-runtime-id", "sample.counter-daemon"),
|
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"),
|
TransportVersion = named.GetValueOrDefault("--transport-version", "cultmesh.v0"),
|
||||||
RulesHash = named.GetValueOrDefault("--rules-hash", "sample-counter-v1"),
|
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)
|
ExpectedCount = named.TryGetValue("--expected-count", out var expectedCount)
|
||||||
? int.Parse(expectedCount)
|
? int.Parse(expectedCount)
|
||||||
: 2,
|
: 2,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ This is the real-process continuation of `samples/eve-two-runtime`:
|
||||||
without application transport or reconnect code;
|
without application transport or reconnect code;
|
||||||
- one separate local Odin fixture answers the canonical CultMesh Verse catalog
|
- one separate local Odin fixture answers the canonical CultMesh Verse catalog
|
||||||
over binary CultNet WebSocket messages;
|
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
|
- the browser and C# clients speak the same binary CultNet schema-v0 WebSocket
|
||||||
messages through the authenticated host adapter.
|
messages through the authenticated host adapter.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,11 @@ try {
|
||||||
window.__sampleCount = decodeCultNetPayload<{ count: number }>(counter.current!).count;
|
window.__sampleCount = decodeCultNetPayload<{ count: number }>(counter.current!).count;
|
||||||
window.__sampleReady = true;
|
window.__sampleReady = true;
|
||||||
} catch (error) {
|
} 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;
|
host.textContent = window.__sampleError;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,11 @@ const eveRoot = resolve(eveFlag >= 0 ? process.argv[eveFlag + 1] : join(repoRoot
|
||||||
const sampleRoot = join(repoRoot, "samples", "eve-browser-network");
|
const sampleRoot = join(repoRoot, "samples", "eve-browser-network");
|
||||||
const workRoot = await mkdtemp(join(tmpdir(), "cultmesh-browser-network-"));
|
const workRoot = await mkdtemp(join(tmpdir(), "cultmesh-browser-network-"));
|
||||||
const statePath = join(workRoot, "counter.cc");
|
const statePath = join(workRoot, "counter.cc");
|
||||||
|
const decoyStatePath = join(workRoot, "decoy-counter.cc");
|
||||||
const bundlePath = join(workRoot, "bundle.js");
|
const bundlePath = join(workRoot, "bundle.js");
|
||||||
const token = "sample-session";
|
const token = "sample-session";
|
||||||
let provider;
|
let provider;
|
||||||
|
let decoyProvider;
|
||||||
let replacementProvider;
|
let replacementProvider;
|
||||||
let odin;
|
let odin;
|
||||||
let headless;
|
let headless;
|
||||||
|
|
@ -41,6 +43,8 @@ try {
|
||||||
alias: {
|
alias: {
|
||||||
"@gamecult/eve-contracts": join(eveRoot, "packages", "eve-contracts", "src", "index.ts"),
|
"@gamecult/eve-contracts": join(eveRoot, "packages", "eve-contracts", "src", "index.ts"),
|
||||||
"@gamecult/eve-browser-lowering": join(eveRoot, "packages", "eve-browser-lowering", "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",
|
logLevel: "warning",
|
||||||
});
|
});
|
||||||
|
|
@ -54,14 +58,22 @@ try {
|
||||||
"--verbosity", "quiet",
|
"--verbosity", "quiet",
|
||||||
"-p:NoWarn=1591%3BCS8632",
|
"-p:NoWarn=1591%3BCS8632",
|
||||||
`-p:EveRoot=${eveRoot}`,
|
`-p:EveRoot=${eveRoot}`,
|
||||||
|
`-p:CultLibRoot=${repoRoot}`,
|
||||||
]);
|
]);
|
||||||
const providerPort = await freePort();
|
const providerPort = await freePort();
|
||||||
|
const decoyProviderPort = await freePort();
|
||||||
const replacementProviderPort = await freePort();
|
const replacementProviderPort = await freePort();
|
||||||
const odinPort = await freePort();
|
const odinPort = await freePort();
|
||||||
const httpPort = await freePort();
|
const httpPort = await freePort();
|
||||||
provider = await startProvider(providerPort);
|
provider = await startProvider(providerPort);
|
||||||
const endpoint = await provider.waitFor("PROVIDER_READY ");
|
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 ");
|
const odinEndpoint = await odin.waitFor("ODIN_READY ");
|
||||||
headless = startDotnet([
|
headless = startDotnet([
|
||||||
"headless", "--odin", odinEndpoint, "--verse-id", "sample.counter",
|
"headless", "--odin", odinEndpoint, "--verse-id", "sample.counter",
|
||||||
|
|
@ -91,7 +103,7 @@ try {
|
||||||
const restartedEndpoint = await replacementProvider.waitFor("PROVIDER_READY ");
|
const restartedEndpoint = await replacementProvider.waitFor("PROVIDER_READY ");
|
||||||
assert.notEqual(restartedEndpoint, endpoint);
|
assert.notEqual(restartedEndpoint, endpoint);
|
||||||
await stop(odin.process);
|
await stop(odin.process);
|
||||||
odin = await startOdin(odinPort, restartedEndpoint);
|
odin = await startOdin(odinPort, restartedEndpoint, decoyEndpoint);
|
||||||
assert.equal(await odin.waitFor("ODIN_READY "), odinEndpoint);
|
assert.equal(await odin.waitFor("ODIN_READY "), odinEndpoint);
|
||||||
await stop(provider.process);
|
await stop(provider.process);
|
||||||
provider = replacementProvider;
|
provider = replacementProvider;
|
||||||
|
|
@ -122,6 +134,7 @@ try {
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
providerEndpoint: endpoint,
|
providerEndpoint: endpoint,
|
||||||
replacementProviderEndpoint: restartedEndpoint,
|
replacementProviderEndpoint: restartedEndpoint,
|
||||||
|
rejectedWrongAuthorityEndpoint: decoyEndpoint,
|
||||||
odinEndpoint,
|
odinEndpoint,
|
||||||
browserRuntime: "chromium",
|
browserRuntime: "chromium",
|
||||||
headlessRuntime: "csharp",
|
headlessRuntime: "csharp",
|
||||||
|
|
@ -136,21 +149,31 @@ try {
|
||||||
if (browser) await browser.close().catch(() => undefined);
|
if (browser) await browser.close().catch(() => undefined);
|
||||||
if (headless) await stop(headless.process);
|
if (headless) await stop(headless.process);
|
||||||
if (provider) await stop(provider.process);
|
if (provider) await stop(provider.process);
|
||||||
|
if (decoyProvider) await stop(decoyProvider.process);
|
||||||
if (replacementProvider) await stop(replacementProvider.process);
|
if (replacementProvider) await stop(replacementProvider.process);
|
||||||
if (odin) await stop(odin.process);
|
if (odin) await stop(odin.process);
|
||||||
if (httpServer) await new Promise(resolve => httpServer.close(resolve));
|
if (httpServer) await new Promise(resolve => httpServer.close(resolve));
|
||||||
await rm(workRoot, { recursive: true, force: true });
|
await rm(workRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startProvider(port) {
|
async function startProvider(port, options = {}) {
|
||||||
return startDotnet(["provider", "--port", String(port), "--state", statePath, "--token", token]);
|
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([
|
return startDotnet([
|
||||||
"odin",
|
"odin",
|
||||||
"--port", String(port),
|
"--port", String(port),
|
||||||
"--provider-endpoint", providerEndpoint,
|
"--provider-endpoint", providerEndpoint,
|
||||||
|
"--decoy-endpoint", decoyEndpoint,
|
||||||
|
"--decoy-authority-runtime-id", "sample.decoy-daemon",
|
||||||
|
"--decoy-route-generation", "sample-decoy-route-1",
|
||||||
"--token", token,
|
"--token", token,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,11 @@ namespace GameCult.Mesh.Quic.Native
|
||||||
|
|
||||||
public async Task<ICultMeshRealtimeTransport> ConnectAsync(
|
public async Task<ICultMeshRealtimeTransport> ConnectAsync(
|
||||||
CultMeshTransportCandidate candidate,
|
CultMeshTransportCandidate candidate,
|
||||||
CultMeshEndpointId endpointId,
|
CultMeshSessionTarget target,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (candidate == null) throw new ArgumentNullException(nameof(candidate));
|
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))
|
if (!TryParseEndpoint(candidate.Endpoint, out var host, out var port, out var pin))
|
||||||
throw new NotSupportedException($"Native QUIC connector does not support '{candidate.Endpoint}'.");
|
throw new NotSupportedException($"Native QUIC connector does not support '{candidate.Endpoint}'.");
|
||||||
|
|
||||||
|
|
@ -72,7 +72,9 @@ namespace GameCult.Mesh.Quic.Native
|
||||||
var transport = new CultMeshNativeQuicRealtimeTransport(
|
var transport = new CultMeshNativeQuicRealtimeTransport(
|
||||||
candidate.Endpoint,
|
candidate.Endpoint,
|
||||||
handle,
|
handle,
|
||||||
_options.PollInterval);
|
_options.PollInterval,
|
||||||
|
target,
|
||||||
|
candidate.Generation);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await transport.WaitUntilConnectedAsync(_options.HandshakeTimeout, cancellationToken).ConfigureAwait(false);
|
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 object _gate = new object();
|
||||||
private readonly TimeSpan _pollInterval;
|
private readonly TimeSpan _pollInterval;
|
||||||
private IntPtr _handle;
|
private IntPtr _handle;
|
||||||
|
private readonly CultMeshSessionTarget _target;
|
||||||
|
private readonly string _routeGeneration;
|
||||||
private bool _disposed;
|
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;
|
Endpoint = endpoint;
|
||||||
_handle = handle;
|
_handle = handle;
|
||||||
_pollInterval = pollInterval;
|
_pollInterval = pollInterval;
|
||||||
|
_target = target;
|
||||||
|
_routeGeneration = routeGeneration ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string TransportId => "msquic-native-realtime";
|
public string TransportId => "msquic-native-realtime";
|
||||||
public string Endpoint { get; }
|
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) =>
|
public Task SendAsync(CultMeshRealtimeFrame frame, CancellationToken cancellationToken = default) =>
|
||||||
Task.FromException(new NotSupportedException(
|
Task.FromException(new NotSupportedException(
|
||||||
"The Unity-compatible native QUIC connector currently owns provider-to-client state only."));
|
"The Unity-compatible native QUIC connector currently owns provider-to-client state only."));
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ public sealed class CultMeshQuicRealtimeConnectorOptions
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional provider-aware certificate validator. Standard TLS validation is used when omitted.
|
/// Optional provider-aware certificate validator. Standard TLS validation is used when omitted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Func<CultMeshEndpointId, X509Certificate2?, X509Chain?, SslPolicyErrors, bool>?
|
public Func<CultMeshRuntimeId, X509Certificate2?, X509Chain?, SslPolicyErrors, bool>?
|
||||||
ValidateProviderCertificate { get; set; }
|
ValidateProviderCertificate { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,11 +55,11 @@ public sealed class CultMeshQuicRealtimeTransportConnector : ICultMeshRealtimeTr
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ICultMeshRealtimeTransport> ConnectAsync(
|
public async Task<ICultMeshRealtimeTransport> ConnectAsync(
|
||||||
CultMeshTransportCandidate candidate,
|
CultMeshTransportCandidate candidate,
|
||||||
CultMeshEndpointId endpointId,
|
CultMeshSessionTarget target,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (candidate == null) throw new ArgumentNullException(nameof(candidate));
|
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)
|
if (!QuicConnection.IsSupported)
|
||||||
throw new PlatformNotSupportedException("QUIC is unavailable. Install a supported MsQuic runtime.");
|
throw new PlatformNotSupportedException("QUIC is unavailable. Install a supported MsQuic runtime.");
|
||||||
if (!TryParseEndpoint(candidate.Endpoint, out var host, out var port))
|
if (!TryParseEndpoint(candidate.Endpoint, out var host, out var port))
|
||||||
|
|
@ -84,14 +84,15 @@ public sealed class CultMeshQuicRealtimeTransportConnector : ICultMeshRealtimeTr
|
||||||
{
|
{
|
||||||
var providerCertificate = certificate as X509Certificate2 ??
|
var providerCertificate = certificate as X509Certificate2 ??
|
||||||
(certificate == null ? null : new X509Certificate2(certificate));
|
(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 ||
|
errors == SslPolicyErrors.None ||
|
||||||
MatchesAdvertisedCertificatePin(candidate.Endpoint, providerCertificate);
|
MatchesAdvertisedCertificatePin(candidate.Endpoint, providerCertificate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, cancellationToken).ConfigureAwait(false);
|
}, 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)
|
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 QuicConnection _connection;
|
||||||
private readonly CancellationTokenSource _shutdown = new();
|
private readonly CancellationTokenSource _shutdown = new();
|
||||||
|
|
@ -318,6 +319,8 @@ internal sealed class CultMeshQuicRealtimeTransport : ICultMeshRealtimeTransport
|
||||||
private readonly SemaphoreSlim _reliableSendGate = new(1, 1);
|
private readonly SemaphoreSlim _reliableSendGate = new(1, 1);
|
||||||
private readonly CultMeshRealtimeInbox _outbound = new();
|
private readonly CultMeshRealtimeInbox _outbound = new();
|
||||||
private readonly Action<CultMeshRealtimeFrame>? _observer;
|
private readonly Action<CultMeshRealtimeFrame>? _observer;
|
||||||
|
private readonly CultMeshSessionTarget? _target;
|
||||||
|
private readonly string _routeGeneration;
|
||||||
private readonly Task _acceptLoop;
|
private readonly Task _acceptLoop;
|
||||||
private readonly Task _outboundLoop;
|
private readonly Task _outboundLoop;
|
||||||
private readonly Task _completion;
|
private readonly Task _completion;
|
||||||
|
|
@ -328,11 +331,15 @@ internal sealed class CultMeshQuicRealtimeTransport : ICultMeshRealtimeTransport
|
||||||
public CultMeshQuicRealtimeTransport(
|
public CultMeshQuicRealtimeTransport(
|
||||||
string endpoint,
|
string endpoint,
|
||||||
QuicConnection connection,
|
QuicConnection connection,
|
||||||
Action<CultMeshRealtimeFrame>? observer = null)
|
Action<CultMeshRealtimeFrame>? observer = null,
|
||||||
|
CultMeshSessionTarget? target = null,
|
||||||
|
string routeGeneration = "")
|
||||||
{
|
{
|
||||||
Endpoint = endpoint;
|
Endpoint = endpoint;
|
||||||
_connection = connection;
|
_connection = connection;
|
||||||
_observer = observer;
|
_observer = observer;
|
||||||
|
_target = target;
|
||||||
|
_routeGeneration = routeGeneration ?? string.Empty;
|
||||||
_acceptLoop = AcceptStreamsAsync(_shutdown.Token);
|
_acceptLoop = AcceptStreamsAsync(_shutdown.Token);
|
||||||
_outboundLoop = SendPublishedFramesAsync(_shutdown.Token);
|
_outboundLoop = SendPublishedFramesAsync(_shutdown.Token);
|
||||||
_completion = Task.WhenAll(_acceptLoop, _outboundLoop);
|
_completion = Task.WhenAll(_acceptLoop, _outboundLoop);
|
||||||
|
|
@ -342,6 +349,17 @@ internal sealed class CultMeshQuicRealtimeTransport : ICultMeshRealtimeTransport
|
||||||
public string Endpoint { get; }
|
public string Endpoint { get; }
|
||||||
public Task Completion => _completion;
|
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)
|
internal bool TryPublishLatest(CultMeshRealtimeFrame frame)
|
||||||
{
|
{
|
||||||
if (_disposed || frame.Delivery != CultMeshRealtimeDelivery.LatestOnly) return false;
|
if (_disposed || frame.Delivery != CultMeshRealtimeDelivery.LatestOnly) return false;
|
||||||
|
|
|
||||||
|
|
@ -391,7 +391,8 @@ namespace GameCult.Mesh
|
||||||
PayloadSchema = requestSchema.Trim(),
|
PayloadSchema = requestSchema.Trim(),
|
||||||
PayloadEncoding = "messagepack-base64",
|
PayloadEncoding = "messagepack-base64",
|
||||||
Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(request, CultNetSchemaMessageSerialization.Options)),
|
Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(request, CultNetSchemaMessageSerialization.Options)),
|
||||||
SourceRuntimeId = sourceRuntimeId.Trim()
|
SourceRuntimeId = sourceRuntimeId.Trim(),
|
||||||
|
TargetRuntimeId = target.AuthorityRuntimeId
|
||||||
};
|
};
|
||||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
timeout.CancelAfter(_operationResponseTimeout);
|
timeout.CancelAfter(_operationResponseTimeout);
|
||||||
|
|
@ -441,6 +442,11 @@ namespace GameCult.Mesh
|
||||||
if (!string.Equals(envelope.ServiceId, expectedService, StringComparison.Ordinal) ||
|
if (!string.Equals(envelope.ServiceId, expectedService, StringComparison.Ordinal) ||
|
||||||
!string.Equals(envelope.Operation, expectedOperation, StringComparison.Ordinal))
|
!string.Equals(envelope.Operation, expectedOperation, StringComparison.Ordinal))
|
||||||
throw new InvalidOperationException("CultMesh operation response did not match the requested service and operation.");
|
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.PayloadSchema, CultNetOperationServer.FailureSchemaId, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
if (!string.Equals(envelope.PayloadEncoding, "messagepack-base64", StringComparison.Ordinal))
|
if (!string.Equals(envelope.PayloadEncoding, "messagepack-base64", StringComparison.Ordinal))
|
||||||
|
|
@ -795,7 +801,7 @@ namespace GameCult.Mesh
|
||||||
_cache,
|
_cache,
|
||||||
new CultRecordKey(recordKey),
|
new CultRecordKey(recordKey),
|
||||||
CultMesh.Verse(target.VerseId, "cultmesh-client").Context,
|
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; }
|
public CultMeshDocumentHandle<TDocument> Handle { get; }
|
||||||
|
|
@ -897,7 +903,7 @@ namespace GameCult.Mesh
|
||||||
_subscription = new CultNetDatabaseSubscriptionClient(session.OpenSchemaClient(), _cache, networkRegistry);
|
_subscription = new CultNetDatabaseSubscriptionClient(session.OpenSchemaClient(), _cache, networkRegistry);
|
||||||
Handle = CultMesh.Collection<TDocument>(
|
Handle = CultMesh.Collection<TDocument>(
|
||||||
_cache,
|
_cache,
|
||||||
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, target.ProviderRuntimeId));
|
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, target.AuthorityRuntimeId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public CultMeshCollectionHandle<TDocument> Handle { get; }
|
public CultMeshCollectionHandle<TDocument> Handle { get; }
|
||||||
|
|
|
||||||
|
|
@ -122,31 +122,56 @@ namespace GameCult.Mesh
|
||||||
|
|
||||||
public async Task<ICultMeshContentTransport> ConnectAsync(
|
public async Task<ICultMeshContentTransport> ConnectAsync(
|
||||||
CultMeshTransportCandidate candidate,
|
CultMeshTransportCandidate candidate,
|
||||||
CultMeshEndpointId endpointId,
|
CultMeshSessionTarget target,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!CanConnect(candidate))
|
if (!CanConnect(candidate))
|
||||||
throw new NotSupportedException($"Legacy RUDP content connector does not support '{candidate.Endpoint}'.");
|
throw new NotSupportedException($"Legacy RUDP content connector does not support '{candidate.Endpoint}'.");
|
||||||
var client = await _schemaConnector.ConnectAsync(candidate, CultMeshProtocols.Content, cancellationToken)
|
var client = await _schemaConnector.ConnectAsync(candidate, CultMeshProtocols.Content, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
return new LegacyRudpContentTransport(candidate.Endpoint, client, _options);
|
try
|
||||||
|
{
|
||||||
|
await CultMeshSessionIdentityClient.VerifyAsync(
|
||||||
|
client,
|
||||||
|
"cultmesh-content-client",
|
||||||
|
target,
|
||||||
|
CultMeshProtocols.Content,
|
||||||
|
candidate.Generation,
|
||||||
|
_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 ICultNetSchemaClient _client;
|
||||||
private readonly CultMeshLegacyRudpContentTransportOptions _options;
|
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 readonly System.Collections.Concurrent.ConcurrentDictionary<string, TaskCompletionSource<CultMeshContentChunkResponseMessage>> _pending = new(StringComparer.Ordinal);
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public LegacyRudpContentTransport(
|
public LegacyRudpContentTransport(
|
||||||
string endpoint,
|
string endpoint,
|
||||||
ICultNetSchemaClient client,
|
ICultNetSchemaClient client,
|
||||||
CultMeshLegacyRudpContentTransportOptions options)
|
CultMeshLegacyRudpContentTransportOptions options,
|
||||||
|
CultMeshSessionTarget target,
|
||||||
|
string routeGeneration)
|
||||||
{
|
{
|
||||||
Endpoint = endpoint;
|
Endpoint = endpoint;
|
||||||
_client = client;
|
_client = client;
|
||||||
_options = options;
|
_options = options;
|
||||||
|
_verseId = target.VerseId;
|
||||||
|
_authorityRuntimeId = target.AuthorityRuntimeId;
|
||||||
|
_routeGeneration = routeGeneration ?? string.Empty;
|
||||||
_client.OnCultNet<CultMeshContentChunkResponseMessage>(OnResponse);
|
_client.OnCultNet<CultMeshContentChunkResponseMessage>(OnResponse);
|
||||||
_client.OnCultNet<CultNetErrorMessage>(OnError);
|
_client.OnCultNet<CultNetErrorMessage>(OnError);
|
||||||
}
|
}
|
||||||
|
|
@ -154,6 +179,16 @@ namespace GameCult.Mesh
|
||||||
public string TransportId => "legacy-rudp-content";
|
public string TransportId => "legacy-rudp-content";
|
||||||
public string Endpoint { get; }
|
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(
|
public async Task CopyChunkToAsync(
|
||||||
CultMeshCdnChunkRef chunk,
|
CultMeshCdnChunkRef chunk,
|
||||||
Stream destination,
|
Stream destination,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ namespace GameCult.Mesh
|
||||||
bool CanConnect(CultMeshTransportCandidate candidate);
|
bool CanConnect(CultMeshTransportCandidate candidate);
|
||||||
Task<ICultMeshContentTransport> ConnectAsync(
|
Task<ICultMeshContentTransport> ConnectAsync(
|
||||||
CultMeshTransportCandidate candidate,
|
CultMeshTransportCandidate candidate,
|
||||||
CultMeshEndpointId endpointId,
|
CultMeshSessionTarget target,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -267,7 +267,8 @@ namespace GameCult.Mesh
|
||||||
var observations = results.SelectMany(result => result.Observations)
|
var observations = results.SelectMany(result => result.Observations)
|
||||||
.Where(observation => query.VerseIds.Count == 0 || query.VerseIds.Contains(observation.Candidate.VerseId, StringComparer.Ordinal))
|
.Where(observation => query.VerseIds.Count == 0 || query.VerseIds.Contains(observation.Candidate.VerseId, StringComparer.Ordinal))
|
||||||
.Where(observation => query.AuthorityRuntimeId == null ||
|
.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();
|
.ToArray();
|
||||||
|
|
||||||
foreach (var rejected in observations.Where(observation => observation.Trust == CultMeshDiscoveryTrust.Rejected))
|
foreach (var rejected in observations.Where(observation => observation.Trust == CultMeshDiscoveryTrust.Rejected))
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ namespace GameCult.Mesh
|
||||||
bool CanConnect(CultMeshTransportCandidate candidate);
|
bool CanConnect(CultMeshTransportCandidate candidate);
|
||||||
Task<ICultMeshRealtimeTransport> ConnectAsync(
|
Task<ICultMeshRealtimeTransport> ConnectAsync(
|
||||||
CultMeshTransportCandidate candidate,
|
CultMeshTransportCandidate candidate,
|
||||||
CultMeshEndpointId endpointId,
|
CultMeshSessionTarget target,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
213
src/GameCult.Mesh/CultMeshSessionIdentity.cs
Normal file
213
src/GameCult.Mesh/CultMeshSessionIdentity.cs
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
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 Func<CultMeshSessionOpenMessage, ICultNetSchemaServerPeer, Task> _handler;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public CultMeshSessionIdentityServer(
|
||||||
|
ICultNetSchemaServer server,
|
||||||
|
string authorityRuntimeId,
|
||||||
|
IEnumerable<string> verseIds,
|
||||||
|
IEnumerable<string> protocolIds,
|
||||||
|
IEnumerable<string> routeGenerations)
|
||||||
|
{
|
||||||
|
_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));
|
||||||
|
_handler = HandleAsync;
|
||||||
|
_server.OnCultNet(_handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_server.RemoveCultNetMessageListener<CultMeshSessionOpenMessage>(_handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleAsync(CultMeshSessionOpenMessage request, ICultNetSchemaServerPeer peer)
|
||||||
|
{
|
||||||
|
var error = Validate(request);
|
||||||
|
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,
|
||||||
|
Error = error
|
||||||
|
});
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (!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 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,
|
||||||
|
string routeGeneration,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (client == null) throw new ArgumentNullException(nameof(client));
|
||||||
|
if (client is ICultMeshVerifiedSchemaClient verified)
|
||||||
|
{
|
||||||
|
if (!verified.IsVerifiedFor(
|
||||||
|
target.VerseId,
|
||||||
|
target.AuthorityRuntimeId,
|
||||||
|
protocol.Value,
|
||||||
|
routeGeneration ?? string.Empty))
|
||||||
|
{
|
||||||
|
throw new CultMeshSessionException(new CultMeshSessionFailure(
|
||||||
|
CultMeshSessionFailureReason.Authority,
|
||||||
|
$"Native transport identity did not match CultMesh target '{target}' for '{protocol}'."));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var messageId = Guid.NewGuid().ToString("N");
|
||||||
|
var completion = new TaskCompletionSource<CultMeshSessionAcceptedMessage>(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
client.OnCultNet<CultMeshSessionAcceptedMessage>(response =>
|
||||||
|
{
|
||||||
|
if (string.Equals(response.MessageId, messageId, StringComparison.Ordinal))
|
||||||
|
completion.TrySetResult(response);
|
||||||
|
});
|
||||||
|
client.SendCultNet(new CultMeshSessionOpenMessage
|
||||||
|
{
|
||||||
|
MessageId = messageId,
|
||||||
|
SourceRuntimeId = sourceRuntimeId,
|
||||||
|
VerseId = target.VerseId,
|
||||||
|
AuthorityRuntimeId = target.AuthorityRuntimeId,
|
||||||
|
ProtocolId = protocol.Value,
|
||||||
|
RouteGeneration = routeGeneration ?? string.Empty
|
||||||
|
});
|
||||||
|
|
||||||
|
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, routeGeneration ?? string.Empty, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new CultMeshSessionException(new CultMeshSessionFailure(
|
||||||
|
CultMeshSessionFailureReason.Authority,
|
||||||
|
$"Route did not prove CultMesh authority '{target}' for protocol '{protocol}': " +
|
||||||
|
(accepted.Error ?? "identity-mismatch")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,14 +9,14 @@ using R3;
|
||||||
|
|
||||||
namespace GameCult.Mesh
|
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 string Value { get; }
|
||||||
public static CultMeshEndpointId Parse(string value) => new CultMeshEndpointId(
|
public static CultMeshRuntimeId Parse(string value) => new CultMeshRuntimeId(
|
||||||
string.IsNullOrWhiteSpace(value) ? throw new FormatException("CultMesh endpoint identity must be non-empty.") : value.Trim());
|
string.IsNullOrWhiteSpace(value) ? throw new FormatException("CultMesh runtime identity must be non-empty.") : value.Trim());
|
||||||
public bool Equals(CultMeshEndpointId? other) => other != null && string.Equals(Value, other.Value, StringComparison.Ordinal);
|
public bool Equals(CultMeshRuntimeId? other) => other != null && string.Equals(Value, other.Value, StringComparison.Ordinal);
|
||||||
public override bool Equals(object? obj) => Equals(obj as CultMeshEndpointId);
|
public override bool Equals(object? obj) => Equals(obj as CultMeshRuntimeId);
|
||||||
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value);
|
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value);
|
||||||
public override string ToString() => Value;
|
public override string ToString() => Value;
|
||||||
}
|
}
|
||||||
|
|
@ -28,31 +28,32 @@ namespace GameCult.Mesh
|
||||||
public sealed class CultMeshSessionTarget : IEquatable<CultMeshSessionTarget>
|
public sealed class CultMeshSessionTarget : IEquatable<CultMeshSessionTarget>
|
||||||
{
|
{
|
||||||
/// <summary>Creates one stable provider target inside one Verse.</summary>
|
/// <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)
|
VerseId = string.IsNullOrWhiteSpace(verseId)
|
||||||
? throw new ArgumentException("Verse identity is required.", nameof(verseId))
|
? throw new ArgumentException("Verse identity is required.", nameof(verseId))
|
||||||
: verseId.Trim();
|
: verseId.Trim();
|
||||||
ProviderEndpointId = CultMeshEndpointId.Parse(providerRuntimeId);
|
AuthorityRuntimeId = CultMeshRuntimeId.Parse(authorityRuntimeId).Value;
|
||||||
ProviderRuntimeId = ProviderEndpointId.Value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Gets the stable Verse identity.</summary>
|
/// <summary>Gets the stable Verse identity.</summary>
|
||||||
public string VerseId { get; }
|
public string VerseId { get; }
|
||||||
/// <summary>Gets the stable provider runtime identity advertised by that Verse.</summary>
|
/// <summary>Gets the stable authority runtime identity advertised by that Verse.</summary>
|
||||||
public string ProviderRuntimeId { get; }
|
public string AuthorityRuntimeId { get; }
|
||||||
internal CultMeshEndpointId ProviderEndpointId { get; }
|
/// <summary>Compatibility projection. Use <see cref="AuthorityRuntimeId"/>.</summary>
|
||||||
internal string SessionKey => VerseId + "\u001f" + ProviderRuntimeId;
|
[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) =>
|
public bool Equals(CultMeshSessionTarget? other) =>
|
||||||
other != null &&
|
other != null &&
|
||||||
string.Equals(VerseId, other.VerseId, StringComparison.Ordinal) &&
|
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 bool Equals(object? obj) => Equals(obj as CultMeshSessionTarget);
|
||||||
public override int GetHashCode() => HashCode.Combine(
|
public override int GetHashCode() => HashCode.Combine(
|
||||||
StringComparer.Ordinal.GetHashCode(VerseId),
|
StringComparer.Ordinal.GetHashCode(VerseId),
|
||||||
StringComparer.Ordinal.GetHashCode(ProviderRuntimeId));
|
StringComparer.Ordinal.GetHashCode(AuthorityRuntimeId));
|
||||||
public override string ToString() => VerseId + "/" + ProviderRuntimeId;
|
public override string ToString() => VerseId + "/" + AuthorityRuntimeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class CultMeshProtocolId : IEquatable<CultMeshProtocolId>
|
public sealed class CultMeshProtocolId : IEquatable<CultMeshProtocolId>
|
||||||
|
|
@ -80,15 +81,24 @@ namespace GameCult.Mesh
|
||||||
|
|
||||||
public sealed class CultMeshTransportCandidate
|
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 = "")
|
||||||
{
|
{
|
||||||
Endpoint = string.IsNullOrWhiteSpace(endpoint) ? throw new ArgumentException("Physical endpoint is required.", nameof(endpoint)) : endpoint;
|
Endpoint = string.IsNullOrWhiteSpace(endpoint) ? throw new ArgumentException("Physical endpoint is required.", nameof(endpoint)) : endpoint;
|
||||||
PathKind = pathKind;
|
PathKind = pathKind;
|
||||||
Priority = priority;
|
Priority = priority;
|
||||||
|
AuthorityRuntimeId = authorityRuntimeId?.Trim() ?? string.Empty;
|
||||||
|
Generation = generation?.Trim() ?? string.Empty;
|
||||||
}
|
}
|
||||||
public string Endpoint { get; }
|
public string Endpoint { get; }
|
||||||
public CultMeshTransportPathKind PathKind { get; }
|
public CultMeshTransportPathKind PathKind { get; }
|
||||||
public int Priority { get; }
|
public int Priority { get; }
|
||||||
|
public string AuthorityRuntimeId { get; }
|
||||||
|
public string Generation { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum CultMeshSessionStatus { Connecting, Online, Degraded, Reconnecting, Offline }
|
public enum CultMeshSessionStatus { Connecting, Online, Degraded, Reconnecting, Offline }
|
||||||
|
|
@ -522,6 +532,8 @@ namespace GameCult.Mesh
|
||||||
public ICultMeshClock Clock { get; set; } = CultMeshSystemClock.Instance;
|
public ICultMeshClock Clock { get; set; } = CultMeshSystemClock.Instance;
|
||||||
public ICultMeshDiagnosticSink Diagnostics { get; set; } = CultMeshNullDiagnosticSink.Instance;
|
public ICultMeshDiagnosticSink Diagnostics { get; set; } = CultMeshNullDiagnosticSink.Instance;
|
||||||
public int MaxRacedCandidates { get; set; } = 2;
|
public int MaxRacedCandidates { get; set; } = 2;
|
||||||
|
public string RuntimeId { get; set; } = "cultmesh-client";
|
||||||
|
public TimeSpan IdentityHandshakeTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class CultMeshSessionManager : IDisposable
|
public sealed class CultMeshSessionManager : IDisposable
|
||||||
|
|
@ -581,6 +593,8 @@ namespace GameCult.Mesh
|
||||||
throw new ArgumentException("Realtime transport connectors cannot contain null entries.", nameof(realtimeConnectors));
|
throw new ArgumentException("Realtime transport connectors cannot contain null entries.", nameof(realtimeConnectors));
|
||||||
_options = options ?? new CultMeshSessionManagerOptions();
|
_options = options ?? new CultMeshSessionManagerOptions();
|
||||||
if (_options.MaxRacedCandidates <= 0) throw new ArgumentOutOfRangeException(nameof(options));
|
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(
|
public async Task<CultMeshSession> ConnectAsync(
|
||||||
|
|
@ -661,14 +675,10 @@ namespace GameCult.Mesh
|
||||||
"No streaming content connectors are configured. Register the TCP content connector or the explicit legacy RUDP connector.");
|
"No streaming content connectors are configured. Register the TCP content connector or the explicit legacy RUDP connector.");
|
||||||
|
|
||||||
var discovery = await _discovery.ResolveAsync(new CultMeshDiscoveryQuery(
|
var discovery = await _discovery.ResolveAsync(new CultMeshDiscoveryQuery(
|
||||||
target.ProviderRuntimeId,
|
target.AuthorityRuntimeId,
|
||||||
new[] { target.VerseId },
|
new[] { target.VerseId },
|
||||||
target.ProviderRuntimeId)).ConfigureAwait(false);
|
target.AuthorityRuntimeId)).ConfigureAwait(false);
|
||||||
var candidates = discovery.Candidates
|
var candidates = BoundCandidates(discovery, target, CultMeshProtocols.Content);
|
||||||
.SelectMany(candidate => candidate.Descriptor.DiscoveryEndpoints)
|
|
||||||
.Distinct(StringComparer.Ordinal)
|
|
||||||
.Select(endpoint => new CultMeshTransportCandidate(endpoint))
|
|
||||||
.ToArray();
|
|
||||||
var routes = candidates
|
var routes = candidates
|
||||||
.SelectMany(candidate => _contentConnectors
|
.SelectMany(candidate => _contentConnectors
|
||||||
.Where(connector => connector.CanConnect(candidate))
|
.Where(connector => connector.CanConnect(candidate))
|
||||||
|
|
@ -685,7 +695,7 @@ namespace GameCult.Mesh
|
||||||
foreach (var tier in routes.GroupBy(route => route.Connector.Priority).OrderBy(group => group.Key))
|
foreach (var tier in routes.GroupBy(route => route.Connector.Priority).OrderBy(group => group.Key))
|
||||||
{
|
{
|
||||||
var attempts = tier.Take(_options.MaxRacedCandidates)
|
var attempts = tier.Take(_options.MaxRacedCandidates)
|
||||||
.Select(route => ConnectContentCandidateAsync(target.ProviderEndpointId, route))
|
.Select(route => ConnectContentCandidateAsync(target, route))
|
||||||
.ToList();
|
.ToList();
|
||||||
while (attempts.Count > 0)
|
while (attempts.Count > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -709,18 +719,33 @@ namespace GameCult.Mesh
|
||||||
}
|
}
|
||||||
|
|
||||||
var last = failures.LastOrDefault();
|
var last = failures.LastOrDefault();
|
||||||
|
var reason = last is CultMeshSessionException typed
|
||||||
|
? typed.Failure.Reason
|
||||||
|
: last is TimeoutException
|
||||||
|
? CultMeshSessionFailureReason.Timeout
|
||||||
|
: CultMeshSessionFailureReason.Transport;
|
||||||
throw Failure(
|
throw Failure(
|
||||||
last is TimeoutException ? CultMeshSessionFailureReason.Timeout : CultMeshSessionFailureReason.Transport,
|
reason,
|
||||||
$"No streaming content path connected for '{target}'.",
|
$"No streaming content path connected for '{target}'.",
|
||||||
last);
|
last);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<ConnectedContentPath> ConnectContentCandidateAsync(
|
private static async Task<ConnectedContentPath> ConnectContentCandidateAsync(
|
||||||
CultMeshEndpointId endpointId,
|
CultMeshSessionTarget target,
|
||||||
ContentRoute route)
|
ContentRoute route)
|
||||||
{
|
{
|
||||||
var transport = await route.Connector.ConnectAsync(route.Candidate, endpointId).ConfigureAwait(false);
|
var transport = await route.Connector.ConnectAsync(route.Candidate, target).ConfigureAwait(false);
|
||||||
return new ConnectedContentPath(route.Candidate, transport);
|
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)
|
private void InvalidateContentSession(string key, CultMeshContentSession session)
|
||||||
|
|
@ -790,13 +815,10 @@ namespace GameCult.Mesh
|
||||||
"No realtime state connectors are configured. Register a QUIC connector explicitly.");
|
"No realtime state connectors are configured. Register a QUIC connector explicitly.");
|
||||||
|
|
||||||
var discovery = await _discovery.ResolveAsync(new CultMeshDiscoveryQuery(
|
var discovery = await _discovery.ResolveAsync(new CultMeshDiscoveryQuery(
|
||||||
target.ProviderRuntimeId,
|
target.AuthorityRuntimeId,
|
||||||
new[] { target.VerseId },
|
new[] { target.VerseId },
|
||||||
target.ProviderRuntimeId)).ConfigureAwait(false);
|
target.AuthorityRuntimeId)).ConfigureAwait(false);
|
||||||
var routes = discovery.Candidates
|
var routes = BoundCandidates(discovery, target, CultMeshProtocols.RealtimeState)
|
||||||
.SelectMany(candidate => candidate.Descriptor.DiscoveryEndpoints)
|
|
||||||
.Distinct(StringComparer.Ordinal)
|
|
||||||
.Select(endpoint => new CultMeshTransportCandidate(endpoint))
|
|
||||||
.SelectMany(candidate => _realtimeConnectors
|
.SelectMany(candidate => _realtimeConnectors
|
||||||
.Where(connector => connector.CanConnect(candidate))
|
.Where(connector => connector.CanConnect(candidate))
|
||||||
.Select(connector => new RealtimeRoute(candidate, connector)))
|
.Select(connector => new RealtimeRoute(candidate, connector)))
|
||||||
|
|
@ -812,7 +834,7 @@ namespace GameCult.Mesh
|
||||||
foreach (var tier in routes.GroupBy(route => route.Connector.Priority).OrderBy(group => group.Key))
|
foreach (var tier in routes.GroupBy(route => route.Connector.Priority).OrderBy(group => group.Key))
|
||||||
{
|
{
|
||||||
var attempts = tier.Take(_options.MaxRacedCandidates)
|
var attempts = tier.Take(_options.MaxRacedCandidates)
|
||||||
.Select(route => ConnectRealtimeCandidateAsync(target.ProviderEndpointId, route))
|
.Select(route => ConnectRealtimeCandidateAsync(target, route))
|
||||||
.ToList();
|
.ToList();
|
||||||
while (attempts.Count > 0)
|
while (attempts.Count > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -836,18 +858,33 @@ namespace GameCult.Mesh
|
||||||
}
|
}
|
||||||
|
|
||||||
var last = failures.LastOrDefault();
|
var last = failures.LastOrDefault();
|
||||||
|
var reason = last is CultMeshSessionException typed
|
||||||
|
? typed.Failure.Reason
|
||||||
|
: last is TimeoutException
|
||||||
|
? CultMeshSessionFailureReason.Timeout
|
||||||
|
: CultMeshSessionFailureReason.Transport;
|
||||||
throw Failure(
|
throw Failure(
|
||||||
last is TimeoutException ? CultMeshSessionFailureReason.Timeout : CultMeshSessionFailureReason.Transport,
|
reason,
|
||||||
$"No realtime state path connected for '{target}'.",
|
$"No realtime state path connected for '{target}'.",
|
||||||
last);
|
last);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<ConnectedRealtimePath> ConnectRealtimeCandidateAsync(
|
private static async Task<ConnectedRealtimePath> ConnectRealtimeCandidateAsync(
|
||||||
CultMeshEndpointId endpointId,
|
CultMeshSessionTarget target,
|
||||||
RealtimeRoute route)
|
RealtimeRoute route)
|
||||||
{
|
{
|
||||||
var transport = await route.Connector.ConnectAsync(route.Candidate, endpointId).ConfigureAwait(false);
|
var transport = await route.Connector.ConnectAsync(route.Candidate, target).ConfigureAwait(false);
|
||||||
return new ConnectedRealtimePath(route.Candidate, transport);
|
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)
|
private void InvalidateRealtimeSession(string key, CultMeshRealtimeSession session)
|
||||||
|
|
@ -880,13 +917,10 @@ namespace GameCult.Mesh
|
||||||
private async Task<ConnectedPath> ResolveConnectedPathAsync(CultMeshSessionTarget target, CultMeshProtocolId protocol)
|
private async Task<ConnectedPath> ResolveConnectedPathAsync(CultMeshSessionTarget target, CultMeshProtocolId protocol)
|
||||||
{
|
{
|
||||||
var discovery = await _discovery.ResolveAsync(new CultMeshDiscoveryQuery(
|
var discovery = await _discovery.ResolveAsync(new CultMeshDiscoveryQuery(
|
||||||
target.ProviderRuntimeId,
|
target.AuthorityRuntimeId,
|
||||||
new[] { target.VerseId },
|
new[] { target.VerseId },
|
||||||
target.ProviderRuntimeId)).ConfigureAwait(false);
|
target.AuthorityRuntimeId)).ConfigureAwait(false);
|
||||||
var candidates = discovery.Candidates.SelectMany(candidate => candidate.Descriptor.DiscoveryEndpoints)
|
var candidates = BoundCandidates(discovery, target, protocol);
|
||||||
.Distinct(StringComparer.Ordinal)
|
|
||||||
.Select(endpoint => new CultMeshTransportCandidate(endpoint))
|
|
||||||
.ToArray();
|
|
||||||
var routes = candidates
|
var routes = candidates
|
||||||
.SelectMany(candidate => _connectors
|
.SelectMany(candidate => _connectors
|
||||||
.Where(connector => connector.CanConnect(candidate))
|
.Where(connector => connector.CanConnect(candidate))
|
||||||
|
|
@ -908,7 +942,7 @@ namespace GameCult.Mesh
|
||||||
foreach (var tier in routes.GroupBy(route => route.Connector.Priority).OrderBy(group => group.Key))
|
foreach (var tier in routes.GroupBy(route => route.Connector.Priority).OrderBy(group => group.Key))
|
||||||
{
|
{
|
||||||
var attempts = tier.Take(_options.MaxRacedCandidates)
|
var attempts = tier.Take(_options.MaxRacedCandidates)
|
||||||
.Select(route => ConnectCandidateAsync(route, protocol))
|
.Select(route => ConnectCandidateAsync(target, route, protocol))
|
||||||
.ToList();
|
.ToList();
|
||||||
while (attempts.Count > 0)
|
while (attempts.Count > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -925,7 +959,12 @@ namespace GameCult.Mesh
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var last = failures.LastOrDefault();
|
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);
|
$"No transport path connected for '{target}' and protocol '{protocol}'.", last);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -960,9 +999,9 @@ namespace GameCult.Mesh
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_discovery.Invalidate(new CultMeshDiscoveryQuery(
|
_discovery.Invalidate(new CultMeshDiscoveryQuery(
|
||||||
session.Target.ProviderRuntimeId,
|
session.Target.AuthorityRuntimeId,
|
||||||
new[] { session.Target.VerseId },
|
new[] { session.Target.VerseId },
|
||||||
session.Target.ProviderRuntimeId));
|
session.Target.AuthorityRuntimeId));
|
||||||
var replacement = await ResolveConnectedPathAsync(session.Target, session.Protocol).ConfigureAwait(false);
|
var replacement = await ResolveConnectedPathAsync(session.Target, session.Protocol).ConfigureAwait(false);
|
||||||
session.ReplacePhysicalChannel(replacement.Client);
|
session.ReplacePhysicalChannel(replacement.Client);
|
||||||
session.Transition(new CultMeshSessionState(
|
session.Transition(new CultMeshSessionState(
|
||||||
|
|
@ -987,10 +1026,28 @@ 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);
|
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.Generation,
|
||||||
|
_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)
|
private static async Task<T> AwaitForCallerAsync<T>(Task<T> shared, CancellationToken cancellationToken)
|
||||||
|
|
@ -1008,12 +1065,36 @@ namespace GameCult.Mesh
|
||||||
private CultMeshSessionException Failure(CultMeshSessionFailureReason reason, string message, Exception? inner = null) =>
|
private CultMeshSessionException Failure(CultMeshSessionFailureReason reason, string message, Exception? inner = null) =>
|
||||||
new CultMeshSessionException(new CultMeshSessionFailure(reason, message), inner);
|
new CultMeshSessionException(new CultMeshSessionFailure(reason, message), inner);
|
||||||
|
|
||||||
|
private static CultMeshTransportCandidate[] BoundCandidates(
|
||||||
|
CultMeshDiscoveryState discovery,
|
||||||
|
CultMeshSessionTarget target,
|
||||||
|
CultMeshProtocolId protocol)
|
||||||
|
{
|
||||||
|
return discovery.Candidates
|
||||||
|
.SelectMany(candidate => candidate.Descriptor.AuthorityRoutes)
|
||||||
|
.Where(route => string.Equals(
|
||||||
|
route.AuthorityRuntimeId,
|
||||||
|
target.AuthorityRuntimeId,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
.Where(route => route.Supports(protocol))
|
||||||
|
.GroupBy(route => route.Endpoint, StringComparer.Ordinal)
|
||||||
|
.Select(group => group.OrderBy(route => route.Priority).First())
|
||||||
|
.Select(route => new CultMeshTransportCandidate(
|
||||||
|
route.Endpoint,
|
||||||
|
priority: route.Priority,
|
||||||
|
authorityRuntimeId: route.AuthorityRuntimeId,
|
||||||
|
generation: route.Generation))
|
||||||
|
.OrderBy(candidate => candidate.Priority)
|
||||||
|
.ThenBy(candidate => candidate.Endpoint, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
private void Emit(CultMeshSessionTarget target, CultMeshProtocolId protocol, string state, string path)
|
private void Emit(CultMeshSessionTarget target, CultMeshProtocolId protocol, string state, string path)
|
||||||
{
|
{
|
||||||
_options.Diagnostics.Emit(new CultMeshDiagnosticEvent(
|
_options.Diagnostics.Emit(new CultMeshDiagnosticEvent(
|
||||||
Interlocked.Increment(ref _diagnosticSequence), _options.Clock.UtcNow,
|
Interlocked.Increment(ref _diagnosticSequence), _options.Clock.UtcNow,
|
||||||
CultMeshReliabilityOrgan.Session, CultMeshDiagnosticKind.PathChanged,
|
CultMeshReliabilityOrgan.Session, CultMeshDiagnosticKind.PathChanged,
|
||||||
target.SessionKey + ":" + protocol.Value, target.ProviderRuntimeId, state,
|
target.SessionKey + ":" + protocol.Value, target.AuthorityRuntimeId, state,
|
||||||
sourceId: "session-manager", endpoint: path));
|
sourceId: "session-manager", endpoint: path));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ namespace GameCult.Mesh
|
||||||
CultNetDocumentRegistry? registry)
|
CultNetDocumentRegistry? registry)
|
||||||
{
|
{
|
||||||
_sharedSession = session ?? throw new ArgumentNullException(nameof(session));
|
_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);
|
_defaults = CultMesh.CloneSnapshotRequestOptions(options);
|
||||||
_registry = registry ?? new CultNetDocumentRegistry();
|
_registry = registry ?? new CultNetDocumentRegistry();
|
||||||
_client = session.Channel;
|
_client = session.Channel;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,21 @@ using GameCult.Networking;
|
||||||
|
|
||||||
namespace GameCult.Mesh
|
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;
|
||||||
|
|
||||||
|
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>
|
/// <summary>Creates dedicated TCP streams for immutable CultMesh content.</summary>
|
||||||
public sealed class CultMeshTcpContentTransportConnector : ICultMeshContentTransportConnector
|
public sealed class CultMeshTcpContentTransportConnector : ICultMeshContentTransportConnector
|
||||||
{
|
{
|
||||||
|
|
@ -33,11 +48,11 @@ namespace GameCult.Mesh
|
||||||
|
|
||||||
public async Task<ICultMeshContentTransport> ConnectAsync(
|
public async Task<ICultMeshContentTransport> ConnectAsync(
|
||||||
CultMeshTransportCandidate candidate,
|
CultMeshTransportCandidate candidate,
|
||||||
CultMeshEndpointId endpointId,
|
CultMeshSessionTarget target,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (candidate == null) throw new ArgumentNullException(nameof(candidate));
|
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))
|
if (!TryParseEndpoint(candidate.Endpoint, out var host, out var port))
|
||||||
throw new NotSupportedException($"TCP content connector does not support '{candidate.Endpoint}'.");
|
throw new NotSupportedException($"TCP content connector does not support '{candidate.Endpoint}'.");
|
||||||
|
|
||||||
|
|
@ -48,8 +63,10 @@ namespace GameCult.Mesh
|
||||||
{
|
{
|
||||||
await CultMeshTcpContentWire.AwaitAsync(client.ConnectAsync(host, port), timeout.Token)
|
await CultMeshTcpContentWire.AwaitAsync(client.ConnectAsync(host, port), timeout.Token)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
await CultMeshTcpContentWire.VerifyAuthorityAsync(
|
||||||
|
client.GetStream(), target, candidate, timeout.Token).ConfigureAwait(false);
|
||||||
Interlocked.Increment(ref _connectCount);
|
Interlocked.Increment(ref _connectCount);
|
||||||
return new CultMeshTcpContentTransport(candidate.Endpoint, client);
|
return new CultMeshTcpContentTransport(candidate.Endpoint, client, target, candidate.Generation);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|
@ -72,7 +89,7 @@ namespace GameCult.Mesh
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class CultMeshTcpContentTransport : ICultMeshContentTransport
|
internal sealed class CultMeshTcpContentTransport : ICultMeshContentTransport, ICultMeshVerifiedTransport
|
||||||
{
|
{
|
||||||
private readonly TcpClient _client;
|
private readonly TcpClient _client;
|
||||||
private readonly NetworkStream _stream;
|
private readonly NetworkStream _stream;
|
||||||
|
|
@ -80,12 +97,22 @@ namespace GameCult.Mesh
|
||||||
private readonly ConcurrentDictionary<string, PendingCopy> _pending = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, PendingCopy> _pending = new(StringComparer.Ordinal);
|
||||||
private readonly CancellationTokenSource _shutdown = new();
|
private readonly CancellationTokenSource _shutdown = new();
|
||||||
private readonly Task _receiveLoop;
|
private readonly Task _receiveLoop;
|
||||||
|
private readonly string _verseId;
|
||||||
|
private readonly string _authorityRuntimeId;
|
||||||
|
private readonly string _routeGeneration;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public CultMeshTcpContentTransport(string endpoint, TcpClient client)
|
public CultMeshTcpContentTransport(
|
||||||
|
string endpoint,
|
||||||
|
TcpClient client,
|
||||||
|
CultMeshSessionTarget target,
|
||||||
|
string routeGeneration)
|
||||||
{
|
{
|
||||||
Endpoint = endpoint;
|
Endpoint = endpoint;
|
||||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
_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();
|
_stream = client.GetStream();
|
||||||
_receiveLoop = ReceiveLoopAsync(_shutdown.Token);
|
_receiveLoop = ReceiveLoopAsync(_shutdown.Token);
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +120,16 @@ namespace GameCult.Mesh
|
||||||
public string TransportId => "tcp-content";
|
public string TransportId => "tcp-content";
|
||||||
public string Endpoint { get; }
|
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(
|
public async Task CopyChunkToAsync(
|
||||||
CultMeshCdnChunkRef chunk,
|
CultMeshCdnChunkRef chunk,
|
||||||
Stream destination,
|
Stream destination,
|
||||||
|
|
@ -212,6 +249,7 @@ namespace GameCult.Mesh
|
||||||
private readonly TcpListener _listener;
|
private readonly TcpListener _listener;
|
||||||
private readonly Func<string, CultMeshCdnArtifactChunk?> _resolveChunk;
|
private readonly Func<string, CultMeshCdnArtifactChunk?> _resolveChunk;
|
||||||
private readonly Func<string, bool> _canServeHash;
|
private readonly Func<string, bool> _canServeHash;
|
||||||
|
private readonly CultMeshTcpContentServerOptions _options;
|
||||||
private readonly CancellationTokenSource _shutdown = new();
|
private readonly CancellationTokenSource _shutdown = new();
|
||||||
private readonly ConcurrentDictionary<TcpClient, byte> _clients = new();
|
private readonly ConcurrentDictionary<TcpClient, byte> _clients = new();
|
||||||
private readonly Task _acceptLoop;
|
private readonly Task _acceptLoop;
|
||||||
|
|
@ -221,8 +259,9 @@ namespace GameCult.Mesh
|
||||||
public CultMeshTcpContentServer(
|
public CultMeshTcpContentServer(
|
||||||
TcpListener listener,
|
TcpListener listener,
|
||||||
CultCache content,
|
CultCache content,
|
||||||
|
CultMeshTcpContentServerOptions options,
|
||||||
Func<string, bool>? canServeHash = null)
|
Func<string, bool>? canServeHash = null)
|
||||||
: this(listener, CreateCacheResolver(content), canServeHash)
|
: this(listener, CreateCacheResolver(content), options, canServeHash)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,10 +273,13 @@ namespace GameCult.Mesh
|
||||||
public CultMeshTcpContentServer(
|
public CultMeshTcpContentServer(
|
||||||
TcpListener listener,
|
TcpListener listener,
|
||||||
Func<string, CultMeshCdnArtifactChunk?> resolveChunk,
|
Func<string, CultMeshCdnArtifactChunk?> resolveChunk,
|
||||||
|
CultMeshTcpContentServerOptions options,
|
||||||
Func<string, bool>? canServeHash = null)
|
Func<string, bool>? canServeHash = null)
|
||||||
{
|
{
|
||||||
_listener = listener ?? throw new ArgumentNullException(nameof(listener));
|
_listener = listener ?? throw new ArgumentNullException(nameof(listener));
|
||||||
_resolveChunk = resolveChunk ?? throw new ArgumentNullException(nameof(resolveChunk));
|
_resolveChunk = resolveChunk ?? throw new ArgumentNullException(nameof(resolveChunk));
|
||||||
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
_options.Validate();
|
||||||
_canServeHash = canServeHash ?? (_ => true);
|
_canServeHash = canServeHash ?? (_ => true);
|
||||||
_listener.Start();
|
_listener.Start();
|
||||||
_acceptLoop = AcceptLoopAsync(_shutdown.Token);
|
_acceptLoop = AcceptLoopAsync(_shutdown.Token);
|
||||||
|
|
@ -278,6 +320,7 @@ namespace GameCult.Mesh
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var stream = client.GetStream();
|
using var stream = client.GetStream();
|
||||||
|
if (!await AcceptAuthorityAsync(stream, cancellationToken).ConfigureAwait(false)) return;
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
var requestBytes = await CultMeshTcpContentWire.ReadFrameAsync(stream, cancellationToken).ConfigureAwait(false);
|
var requestBytes = await CultMeshTcpContentWire.ReadFrameAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
@ -296,6 +339,32 @@ 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.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,
|
||||||
|
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 async Task SendChunkAsync(
|
private async Task SendChunkAsync(
|
||||||
Stream stream,
|
Stream stream,
|
||||||
CultMeshContentChunkRequestMessage request,
|
CultMeshContentChunkRequestMessage request,
|
||||||
|
|
@ -391,6 +460,38 @@ namespace GameCult.Mesh
|
||||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
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
|
||||||
|
};
|
||||||
|
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))
|
||||||
|
throw new CultMeshSessionException(new CultMeshSessionFailure(
|
||||||
|
CultMeshSessionFailureReason.Authority,
|
||||||
|
response.Error ?? "TCP content endpoint did not prove the selected authority route.",
|
||||||
|
candidate.Endpoint));
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task<byte[]> ReadFrameAsync(Stream stream, CancellationToken cancellationToken)
|
public static async Task<byte[]> ReadFrameAsync(Stream stream, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var header = new byte[HeaderBytes];
|
var header = new byte[HeaderBytes];
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,53 @@ 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)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string AuthorityRuntimeId { get; }
|
||||||
|
public string Endpoint { get; }
|
||||||
|
public IReadOnlyList<string> ProtocolIds { get; }
|
||||||
|
public int Priority { get; }
|
||||||
|
public string Generation { 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>
|
/// <summary>
|
||||||
/// Describes one CultMesh Verse: a rule-bearing consensus graph.
|
/// Describes one CultMesh Verse: a rule-bearing consensus graph.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -107,14 +154,36 @@ namespace GameCult.Mesh
|
||||||
IEnumerable<string>? discoveryEndpoints = null,
|
IEnumerable<string>? discoveryEndpoints = null,
|
||||||
IEnumerable<string>? authorityRuntimeIds = null,
|
IEnumerable<string>? authorityRuntimeIds = null,
|
||||||
string? parentVerseId = null,
|
string? parentVerseId = null,
|
||||||
string? description = null)
|
string? description = null,
|
||||||
|
IEnumerable<CultMeshAuthorityRoute>? authorityRoutes = null)
|
||||||
{
|
{
|
||||||
VerseId = RequireNonEmpty(verseId, nameof(verseId));
|
VerseId = RequireNonEmpty(verseId, nameof(verseId));
|
||||||
DisplayName = RequireNonEmpty(displayName, nameof(displayName));
|
DisplayName = RequireNonEmpty(displayName, nameof(displayName));
|
||||||
AuthorityModel = authorityModel;
|
AuthorityModel = authorityModel;
|
||||||
Compatibility = compatibility ?? throw new ArgumentNullException(nameof(compatibility));
|
Compatibility = compatibility ?? throw new ArgumentNullException(nameof(compatibility));
|
||||||
DiscoveryEndpoints = Clean(discoveryEndpoints);
|
var legacyEndpoints = Clean(discoveryEndpoints);
|
||||||
AuthorityRuntimeIds = Clean(authorityRuntimeIds);
|
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;
|
ParentVerseId = parentVerseId;
|
||||||
Description = description;
|
Description = description;
|
||||||
}
|
}
|
||||||
|
|
@ -143,6 +212,8 @@ namespace GameCult.Mesh
|
||||||
/// Gets known authoritative runtime ids, when authority is cluster-shaped.
|
/// Gets known authoritative runtime ids, when authority is cluster-shaped.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<string> AuthorityRuntimeIds { get; }
|
public IReadOnlyList<string> AuthorityRuntimeIds { get; }
|
||||||
|
/// <summary>Gets exact authority-to-route bindings used for identity-sensitive resolution.</summary>
|
||||||
|
public IReadOnlyList<CultMeshAuthorityRoute> AuthorityRoutes { get; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the parent Verse id for subscribed overlays or branches.
|
/// Gets the parent Verse id for subscribed overlays or branches.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -177,10 +248,29 @@ namespace GameCult.Mesh
|
||||||
|
|
||||||
private static string[] Clean(IEnumerable<string>? values)
|
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>();
|
?? 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)
|
private static string RequireNonEmpty(string value, string paramName)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@ namespace GameCult.Mesh
|
||||||
},
|
},
|
||||||
DiscoveryEndpoints = verse.DiscoveryEndpoints.ToArray(),
|
DiscoveryEndpoints = verse.DiscoveryEndpoints.ToArray(),
|
||||||
AuthorityRuntimeIds = verse.AuthorityRuntimeIds.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
|
||||||
|
}).ToArray(),
|
||||||
ParentVerseId = verse.ParentVerseId,
|
ParentVerseId = verse.ParentVerseId,
|
||||||
Description = verse.Description
|
Description = verse.Description
|
||||||
};
|
};
|
||||||
|
|
@ -50,6 +58,12 @@ namespace GameCult.Mesh
|
||||||
out var parsed)
|
out var parsed)
|
||||||
? parsed
|
? parsed
|
||||||
: CultMeshVerseAuthorityModel.SubscribedOverlay;
|
: CultMeshVerseAuthorityModel.SubscribedOverlay;
|
||||||
|
var routes = message.AuthorityRoutes?.Select(route => new CultMeshAuthorityRoute(
|
||||||
|
route.AuthorityRuntimeId,
|
||||||
|
route.Endpoint,
|
||||||
|
route.ProtocolIds,
|
||||||
|
route.Priority,
|
||||||
|
route.Generation)).ToArray();
|
||||||
return new CultMeshVerseDescriptor(
|
return new CultMeshVerseDescriptor(
|
||||||
message.VerseId,
|
message.VerseId,
|
||||||
message.DisplayName,
|
message.DisplayName,
|
||||||
|
|
@ -60,10 +74,11 @@ namespace GameCult.Mesh
|
||||||
message.Compatibility.CompatibleVerseIds,
|
message.Compatibility.CompatibleVerseIds,
|
||||||
message.Compatibility.RequiredPluginIds,
|
message.Compatibility.RequiredPluginIds,
|
||||||
message.Compatibility.OptionalPluginIds),
|
message.Compatibility.OptionalPluginIds),
|
||||||
message.DiscoveryEndpoints,
|
routes is { Length: > 0 } ? null : message.DiscoveryEndpoints,
|
||||||
message.AuthorityRuntimeIds,
|
routes is { Length: > 0 } ? null : message.AuthorityRuntimeIds,
|
||||||
message.ParentVerseId,
|
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>
|
/// <summary>Creates the canonical filtered Verse catalog response for any CultNet transport adapter.</summary>
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,12 @@ not the brand surface.
|
||||||
### Verses
|
### Verses
|
||||||
|
|
||||||
`CultMeshVerseDescriptor` describes a rule-bearing consensus graph. It includes
|
`CultMeshVerseDescriptor` describes a rule-bearing consensus graph. It includes
|
||||||
transport compatibility, rules hash, authority model, discovery endpoints,
|
transport compatibility, rules hash, authority model, exact authority-route
|
||||||
known authority runtimes, optional parent Verse id, and plugin requirements.
|
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.
|
`CultMeshVerseCatalog` is the local reactive catalog for discovered Verses.
|
||||||
It can publish discovery updates and find compatible transfer targets.
|
It can publish discovery updates and find compatible transfer targets.
|
||||||
|
|
|
||||||
|
|
@ -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
|
registered connector for each traffic class. A connector owns only its physical
|
||||||
connection and byte movement.
|
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
|
connector support and priority, caller cancellation, and locality evidence for
|
||||||
body negotiation.
|
body negotiation.
|
||||||
|
|
||||||
|
|
@ -63,13 +64,22 @@ operation.
|
||||||
transport diagnostics. None of these grant provider authority or become content
|
transport diagnostics. None of these grant provider authority or become content
|
||||||
identity.
|
identity.
|
||||||
|
|
||||||
|
Before a schema, content, or realtime session becomes online, the transport
|
||||||
|
must prove that the connected peer matches the selected Verse, authority
|
||||||
|
runtime, protocol, and route generation. TCP schema and content lanes use the
|
||||||
|
portable CultNet session-open handshake. QUIC uses the exact Odin-bound route
|
||||||
|
plus its TLS endpoint/certificate proof. A connector that returns bytes without
|
||||||
|
authority proof is rejected; racing every endpoint in a Verse descriptor is
|
||||||
|
not route selection.
|
||||||
|
|
||||||
**Forbidden writers:** applications and renderers do not choose unadvertised
|
**Forbidden writers:** applications and renderers do not choose unadvertised
|
||||||
endpoints, implement reconnect loops, or bypass verification. Schema messages
|
endpoints, implement reconnect loops, or bypass verification. Schema messages
|
||||||
must not carry bulk bodies. RUDP/LiteNetLib cannot become an implicit default
|
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
|
for any traffic class. QUIC does not own schemas or immutable files merely
|
||||||
because it can transport bytes.
|
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
|
class has its own connector set, connection cache, priority tiers, and failure
|
||||||
state. All content connectors feed the same transfer and promotion owner.
|
state. All content connectors feed the same transfer and promotion owner.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,10 @@ The same transport can carry many Verses:
|
||||||
subscribed overlay
|
subscribed overlay
|
||||||
- `Compatibility`: transport version, rules hash, compatible Verse ids, and
|
- `Compatibility`: transport version, rules hash, compatible Verse ids, and
|
||||||
plugin requirements
|
plugin requirements
|
||||||
- `DiscoveryEndpoints`: endpoints where nodes for the Verse can be found
|
- `AuthorityRoutes`: exact authority-runtime, endpoint, protocol, priority, and
|
||||||
- `AuthorityRuntimeIds`: known authoritative runtimes when cluster-shaped
|
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
|
- `ParentVerseId`: source Verse for overlays or branches
|
||||||
|
|
||||||
## Compatibility
|
## 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
|
where the descriptor came from. That keeps discovery pluggable while the Verse
|
||||||
model stays stable.
|
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
|
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
|
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,
|
serve or observe parts of that graph. Peer cards can advertise discovery,
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,16 @@ namespace GameCult.Networking
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
if (request == null) throw new ArgumentNullException(nameof(request));
|
if (request == null) throw new ArgumentNullException(nameof(request));
|
||||||
if (peer == null) throw new ArgumentNullException(nameof(peer));
|
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;
|
Route route;
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,8 @@ namespace GameCult.Networking
|
||||||
CultNetSchemaVersions.SimulationConsensusCandidate => Deserialize<CultNetSimulationConsensusCandidateMessage>(payload),
|
CultNetSchemaVersions.SimulationConsensusCandidate => Deserialize<CultNetSimulationConsensusCandidateMessage>(payload),
|
||||||
CultNetSchemaVersions.VerseCatalogRequest => Deserialize<CultMeshVerseCatalogRequestMessage>(payload),
|
CultNetSchemaVersions.VerseCatalogRequest => Deserialize<CultMeshVerseCatalogRequestMessage>(payload),
|
||||||
CultNetSchemaVersions.VerseCatalogResponse => Deserialize<CultMeshVerseCatalogResponseMessage>(payload),
|
CultNetSchemaVersions.VerseCatalogResponse => Deserialize<CultMeshVerseCatalogResponseMessage>(payload),
|
||||||
|
CultNetSchemaVersions.SessionOpen => Deserialize<CultMeshSessionOpenMessage>(payload),
|
||||||
|
CultNetSchemaVersions.SessionAccepted => Deserialize<CultMeshSessionAcceptedMessage>(payload),
|
||||||
CultNetSchemaVersions.PeerExchangeRequest => Deserialize<CultMeshPeerExchangeRequestMessage>(payload),
|
CultNetSchemaVersions.PeerExchangeRequest => Deserialize<CultMeshPeerExchangeRequestMessage>(payload),
|
||||||
CultNetSchemaVersions.PeerExchangeResponse => Deserialize<CultMeshPeerExchangeResponseMessage>(payload),
|
CultNetSchemaVersions.PeerExchangeResponse => Deserialize<CultMeshPeerExchangeResponseMessage>(payload),
|
||||||
CultNetSchemaVersions.ContentChunkRequest => Deserialize<CultMeshContentChunkRequestMessage>(payload),
|
CultNetSchemaVersions.ContentChunkRequest => Deserialize<CultMeshContentChunkRequestMessage>(payload),
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,10 @@ namespace GameCult.Networking
|
||||||
/// CultMesh Verse catalog response contract identifier.
|
/// CultMesh Verse catalog response contract identifier.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string VerseCatalogResponse = "cultmesh.verse_catalog_response.v0";
|
public const string VerseCatalogResponse = "cultmesh.verse_catalog_response.v0";
|
||||||
|
/// <summary>CultMesh session identity request contract identifier.</summary>
|
||||||
|
public const string SessionOpen = "cultmesh.session_open.v1";
|
||||||
|
/// <summary>CultMesh verified session identity response contract identifier.</summary>
|
||||||
|
public const string SessionAccepted = "cultmesh.session_accepted.v1";
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// CultMesh peer exchange request contract identifier.
|
/// CultMesh peer exchange request contract identifier.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -1179,6 +1183,33 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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("error")] public string? Error { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Requests known CultMesh Verses from a peer.
|
/// Requests known CultMesh Verses from a peer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -1254,6 +1285,11 @@ namespace GameCult.Networking
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Key("authorityRuntimeIds")] public string[] AuthorityRuntimeIds { get; set; } = Array.Empty<string>();
|
[Key("authorityRuntimeIds")] public string[] AuthorityRuntimeIds { get; set; } = Array.Empty<string>();
|
||||||
/// <summary>
|
/// <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.
|
/// Gets or sets the parent Verse id for overlays or branches.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Key("parentVerseId")] public string? ParentVerseId { get; set; }
|
[Key("parentVerseId")] public string? ParentVerseId { get; set; }
|
||||||
|
|
@ -1263,6 +1299,17 @@ namespace GameCult.Networking
|
||||||
[Key("description")] public string? Description { get; set; }
|
[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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wire representation of CultMesh Verse compatibility.
|
/// Wire representation of CultMesh Verse compatibility.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ public sealed class CultMeshNativeQuicRealtimeTransportTests
|
||||||
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
|
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
|
||||||
using var client = await connector.ConnectAsync(
|
using var client = await connector.ConnectAsync(
|
||||||
new CultMeshTransportCandidate(endpoint!),
|
new CultMeshTransportCandidate(endpoint!),
|
||||||
CultMeshEndpointId.Parse("aetheria.local"));
|
new CultMeshSessionTarget("aetheria", "aetheria.local"));
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
var frame = await client.ReceiveAsync(timeout.Token);
|
var frame = await client.ReceiveAsync(timeout.Token);
|
||||||
|
|
||||||
|
|
@ -49,7 +49,7 @@ public sealed class CultMeshNativeQuicRealtimeTransportTests
|
||||||
|
|
||||||
using var client = await connector.ConnectAsync(
|
using var client = await connector.ConnectAsync(
|
||||||
new CultMeshTransportCandidate(endpoint),
|
new CultMeshTransportCandidate(endpoint),
|
||||||
CultMeshEndpointId.Parse("aetheria.local"));
|
new CultMeshSessionTarget("aetheria", "aetheria.local"));
|
||||||
var expected = new CultMeshRealtimeFrame
|
var expected = new CultMeshRealtimeFrame
|
||||||
{
|
{
|
||||||
ChannelId = "aetheria.entities",
|
ChannelId = "aetheria.entities",
|
||||||
|
|
@ -92,7 +92,7 @@ public sealed class CultMeshNativeQuicRealtimeTransportTests
|
||||||
|
|
||||||
var connect = () => connector.ConnectAsync(
|
var connect = () => connector.ConnectAsync(
|
||||||
new CultMeshTransportCandidate(endpoint),
|
new CultMeshTransportCandidate(endpoint),
|
||||||
CultMeshEndpointId.Parse("aetheria.local"));
|
new CultMeshSessionTarget("aetheria", "aetheria.local"));
|
||||||
|
|
||||||
await connect.Should().ThrowAsync<IOException>()
|
await connect.Should().ThrowAsync<IOException>()
|
||||||
.WithMessage("*certificate*pin*");
|
.WithMessage("*certificate*pin*");
|
||||||
|
|
@ -114,10 +114,10 @@ public sealed class CultMeshNativeQuicRealtimeTransportTests
|
||||||
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
|
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
|
||||||
var departedClient = await connector.ConnectAsync(
|
var departedClient = await connector.ConnectAsync(
|
||||||
new CultMeshTransportCandidate(endpoint),
|
new CultMeshTransportCandidate(endpoint),
|
||||||
CultMeshEndpointId.Parse("aetheria.local"));
|
new CultMeshSessionTarget("aetheria", "aetheria.local"));
|
||||||
using var healthyClient = await connector.ConnectAsync(
|
using var healthyClient = await connector.ConnectAsync(
|
||||||
new CultMeshTransportCandidate(endpoint),
|
new CultMeshTransportCandidate(endpoint),
|
||||||
CultMeshEndpointId.Parse("aetheria.local"));
|
new CultMeshSessionTarget("aetheria", "aetheria.local"));
|
||||||
await WaitUntilAsync(() => server.ConnectionCount == 2);
|
await WaitUntilAsync(() => server.ConnectionCount == 2);
|
||||||
await server.BroadcastAsync(Frame(sequence: 1));
|
await server.BroadcastAsync(Frame(sequence: 1));
|
||||||
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ public sealed class CultMeshQuicRealtimeTransportTests
|
||||||
});
|
});
|
||||||
using var client = await connector.ConnectAsync(
|
using var client = await connector.ConnectAsync(
|
||||||
new CultMeshTransportCandidate($"cultmesh-state+quic://127.0.0.1:{listener.LocalEndPoint.Port}"),
|
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;
|
await using var serverConnection = await accept;
|
||||||
|
|
||||||
client.TransportId.Should().Be("msquic-realtime");
|
client.TransportId.Should().Be("msquic-realtime");
|
||||||
|
|
@ -62,7 +62,7 @@ public sealed class CultMeshQuicRealtimeTransportTests
|
||||||
|
|
||||||
using var client = await connector.ConnectAsync(
|
using var client = await connector.ConnectAsync(
|
||||||
new CultMeshTransportCandidate(endpoint),
|
new CultMeshTransportCandidate(endpoint),
|
||||||
CultMeshEndpointId.Parse("service:aetheria.daemon"));
|
new CultMeshSessionTarget("aetheria", "service:aetheria.daemon"));
|
||||||
|
|
||||||
client.TransportId.Should().Be("msquic-realtime");
|
client.TransportId.Should().Be("msquic-realtime");
|
||||||
await WaitUntilAsync(() => server.ConnectionCount == 1);
|
await WaitUntilAsync(() => server.ConnectionCount == 1);
|
||||||
|
|
@ -78,8 +78,8 @@ public sealed class CultMeshQuicRealtimeTransportTests
|
||||||
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
|
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
|
||||||
ServerCertificate = certificate
|
ServerCertificate = certificate
|
||||||
});
|
});
|
||||||
var endpointId = CultMeshEndpointId.Parse("service:aetheria.daemon");
|
var runtimeId = CultMeshRuntimeId.Parse("service:aetheria.daemon");
|
||||||
var target = new CultMeshSessionTarget("aetheria", endpointId.Value);
|
var target = new CultMeshSessionTarget("aetheria", runtimeId.Value);
|
||||||
using var sessions = CreateSessions(server, out var validatedIdentity);
|
using var sessions = CreateSessions(server, out var validatedIdentity);
|
||||||
CultMeshRealtimeSession session;
|
CultMeshRealtimeSession session;
|
||||||
try
|
try
|
||||||
|
|
@ -105,7 +105,7 @@ public sealed class CultMeshQuicRealtimeTransportTests
|
||||||
new[] { firstAtServer.Sequence, secondAtServer.Sequence }.Should().Equal(1, 2);
|
new[] { firstAtServer.Sequence, secondAtServer.Sequence }.Should().Equal(1, 2);
|
||||||
new[] { firstAtClient.Sequence, secondAtClient.Sequence }.Should().Equal(3, 4);
|
new[] { firstAtClient.Sequence, secondAtClient.Sequence }.Should().Equal(3, 4);
|
||||||
firstAtClient.Payload.ToArray().Should().Equal(3, 4, 5);
|
firstAtClient.Payload.ToArray().Should().Equal(3, 4, 5);
|
||||||
validatedIdentity.Value.Should().Be(endpointId.Value);
|
validatedIdentity.Value.Should().Be(runtimeId.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
@ -118,8 +118,8 @@ public sealed class CultMeshQuicRealtimeTransportTests
|
||||||
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
|
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
|
||||||
ServerCertificate = certificate
|
ServerCertificate = certificate
|
||||||
});
|
});
|
||||||
var endpointId = CultMeshEndpointId.Parse("service:aetheria.daemon");
|
var runtimeId = CultMeshRuntimeId.Parse("service:aetheria.daemon");
|
||||||
var target = new CultMeshSessionTarget("aetheria", endpointId.Value);
|
var target = new CultMeshSessionTarget("aetheria", runtimeId.Value);
|
||||||
using var sessions = CreateSessions(server, out _);
|
using var sessions = CreateSessions(server, out _);
|
||||||
var session = await sessions.ConnectRealtimeAsync(target);
|
var session = await sessions.ConnectRealtimeAsync(target);
|
||||||
await WaitUntilAsync(() => server.ConnectionCount == 1);
|
await WaitUntilAsync(() => server.ConnectionCount == 1);
|
||||||
|
|
@ -148,8 +148,8 @@ public sealed class CultMeshQuicRealtimeTransportTests
|
||||||
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
|
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
|
||||||
ServerCertificate = certificate
|
ServerCertificate = certificate
|
||||||
});
|
});
|
||||||
var endpointId = CultMeshEndpointId.Parse("service:aetheria.daemon");
|
var runtimeId = CultMeshRuntimeId.Parse("service:aetheria.daemon");
|
||||||
var target = new CultMeshSessionTarget("aetheria", endpointId.Value);
|
var target = new CultMeshSessionTarget("aetheria", runtimeId.Value);
|
||||||
using var sessions = CreateSessions(server, out _);
|
using var sessions = CreateSessions(server, out _);
|
||||||
var session = await sessions.ConnectRealtimeAsync(target);
|
var session = await sessions.ConnectRealtimeAsync(target);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,13 @@ public sealed class CultMeshBodySessionTests
|
||||||
RuntimeId = "cultmesh-body-rudp-test", Socket = socket, MaxFragmentBytes = 1024,
|
RuntimeId = "cultmesh-body-rudp-test", Socket = socket, MaxFragmentBytes = 1024,
|
||||||
MaxPendingReliablePackets = 8192
|
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 bodyServer = new CultMeshBodyServer(server, store);
|
||||||
using var pumpCancellation = new CancellationTokenSource();
|
using var pumpCancellation = new CancellationTokenSource();
|
||||||
var pump = Task.Run(async () =>
|
var pump = Task.Run(async () =>
|
||||||
|
|
@ -64,7 +71,6 @@ public sealed class CultMeshBodySessionTests
|
||||||
if (progress.TransportItemsConsumed == 0) await Task.Delay(1, pumpCancellation.Token);
|
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 discovery = new CultMeshDiscoveryService(new[] { new RouteSource(endpoint) });
|
||||||
using var sessions = new CultMeshSessionManager(discovery,
|
using var sessions = new CultMeshSessionManager(discovery,
|
||||||
new ICultMeshTransportConnector[] { new CultMeshSchemaTransportConnector() });
|
new ICultMeshTransportConnector[] { new CultMeshSchemaTransportConnector() });
|
||||||
|
|
@ -179,7 +185,7 @@ public sealed class CultMeshBodySessionTests
|
||||||
((Func<T, ICultNetSchemaServerPeer, Task>)_handlers[typeof(T)])(message, peer);
|
((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 LoopbackServer _server;
|
||||||
private readonly Dictionary<Type, List<Delegate>> _handlers = new();
|
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);
|
foreach (var value in values.ToArray()) ((Action<T>)value)(message);
|
||||||
}
|
}
|
||||||
public void Dispose() { }
|
public void Dispose() { }
|
||||||
|
public bool IsVerifiedFor(string verseId, string authorityRuntimeId, string protocolId, string routeGeneration) =>
|
||||||
|
verseId == "aetheria" && authorityRuntimeId == "aetheria.daemon";
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class LoopbackPeer : ICultNetSchemaServerPeer
|
private sealed class LoopbackPeer : ICultNetSchemaServerPeer
|
||||||
|
|
|
||||||
|
|
@ -303,7 +303,7 @@ public sealed class CultMeshClientTests
|
||||||
|
|
||||||
result.MessageId.Should().Be("pilot-command-1");
|
result.MessageId.Should().Be("pilot-command-1");
|
||||||
result.Status.Should().Be("accepted");
|
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.ReceiptId.Should().Be("receipt:pilot-command-1");
|
||||||
result.Value.Count.Should().Be(2);
|
result.Value.Count.Should().Be(2);
|
||||||
connector.Clients.Should().ContainSingle();
|
connector.Clients.Should().ContainSingle();
|
||||||
|
|
@ -370,6 +370,31 @@ public sealed class CultMeshClientTests
|
||||||
connector.Clients[0].Operations.Should().ContainSingle();
|
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() },
|
||||||
|
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]
|
[Test]
|
||||||
public async Task Dispose_TerminatesDocumentWaitingForInitialSnapshot()
|
public async Task Dispose_TerminatesDocumentWaitingForInitialSnapshot()
|
||||||
{
|
{
|
||||||
|
|
@ -438,13 +463,15 @@ public sealed class CultMeshClientTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ConnectedClient : ICultNetSchemaClient
|
private sealed class ConnectedClient : ICultNetSchemaClient, ICultMeshVerifiedSchemaClient
|
||||||
{
|
{
|
||||||
public bool Connected => true;
|
public bool Connected => true;
|
||||||
public void Connect(string host, int port) { }
|
public void Connect(string host, int port) { }
|
||||||
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage { }
|
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage { }
|
||||||
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage { }
|
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage { }
|
||||||
public void Dispose() { }
|
public void Dispose() { }
|
||||||
|
public bool IsVerifiedFor(string verseId, string authorityRuntimeId, string protocolId, string routeGeneration) =>
|
||||||
|
verseId == "aetheria" && authorityRuntimeId == "aetheria-daemon";
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class DocumentConnector : ICultMeshTransportConnector
|
private sealed class DocumentConnector : ICultMeshTransportConnector
|
||||||
|
|
@ -454,6 +481,7 @@ public sealed class CultMeshClientTests
|
||||||
public bool SuppressFirstSubscriptionSnapshot { get; set; }
|
public bool SuppressFirstSubscriptionSnapshot { get; set; }
|
||||||
public bool RejectOperations { get; set; }
|
public bool RejectOperations { get; set; }
|
||||||
public bool FailFirstOperationBeforeResponse { get; set; }
|
public bool FailFirstOperationBeforeResponse { get; set; }
|
||||||
|
public string ResponseSourceRuntimeId { get; set; } = "aetheria-daemon";
|
||||||
public string ConnectorId => "document-test";
|
public string ConnectorId => "document-test";
|
||||||
public int Priority => 0;
|
public int Priority => 0;
|
||||||
public int SubscribeCount => Clients.Sum(client => client.SubscribeCount);
|
public int SubscribeCount => Clients.Sum(client => client.SubscribeCount);
|
||||||
|
|
@ -469,13 +497,14 @@ public sealed class CultMeshClientTests
|
||||||
? int.MaxValue
|
? int.MaxValue
|
||||||
: SuppressFirstSubscriptionSnapshot && Clients.Count == 0 ? 1 : 0,
|
: SuppressFirstSubscriptionSnapshot && Clients.Count == 0 ? 1 : 0,
|
||||||
rejectOperations: RejectOperations,
|
rejectOperations: RejectOperations,
|
||||||
failOperationBeforeResponse: FailFirstOperationBeforeResponse && Clients.Count == 0);
|
failOperationBeforeResponse: FailFirstOperationBeforeResponse && Clients.Count == 0,
|
||||||
|
responseSourceRuntimeId: ResponseSourceRuntimeId);
|
||||||
Clients.Add(client);
|
Clients.Add(client);
|
||||||
return Task.FromResult<ICultNetSchemaClient>(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<CultNetSnapshotResponseRawMessage>> _snapshots = new();
|
||||||
private readonly List<Action<CultNetOperationResponseMessage>> _operationResponses = new();
|
private readonly List<Action<CultNetOperationResponseMessage>> _operationResponses = new();
|
||||||
|
|
@ -484,17 +513,20 @@ public sealed class CultMeshClientTests
|
||||||
private readonly TaskCompletionSource<Exception> _failure = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
private readonly TaskCompletionSource<Exception> _failure = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
private readonly bool _rejectOperations;
|
private readonly bool _rejectOperations;
|
||||||
private bool _failOperationBeforeResponse;
|
private bool _failOperationBeforeResponse;
|
||||||
|
private readonly string _responseSourceRuntimeId;
|
||||||
private int _snapshotsToSuppress;
|
private int _snapshotsToSuppress;
|
||||||
public DocumentClient(
|
public DocumentClient(
|
||||||
string text,
|
string text,
|
||||||
int snapshotsToSuppress = 0,
|
int snapshotsToSuppress = 0,
|
||||||
bool rejectOperations = false,
|
bool rejectOperations = false,
|
||||||
bool failOperationBeforeResponse = false)
|
bool failOperationBeforeResponse = false,
|
||||||
|
string responseSourceRuntimeId = "aetheria-daemon")
|
||||||
{
|
{
|
||||||
_text = text;
|
_text = text;
|
||||||
_snapshotsToSuppress = snapshotsToSuppress;
|
_snapshotsToSuppress = snapshotsToSuppress;
|
||||||
_rejectOperations = rejectOperations;
|
_rejectOperations = rejectOperations;
|
||||||
_failOperationBeforeResponse = failOperationBeforeResponse;
|
_failOperationBeforeResponse = failOperationBeforeResponse;
|
||||||
|
_responseSourceRuntimeId = responseSourceRuntimeId;
|
||||||
var cache = CultMesh.CreateCultCacheDocumentRegistry(typeof(ClientTestDocument));
|
var cache = CultMesh.CreateCultCacheDocumentRegistry(typeof(ClientTestDocument));
|
||||||
_documents = CultMesh.CreateCultNetDocumentRegistry(new[] { typeof(ClientTestDocument) }, cache);
|
_documents = CultMesh.CreateCultNetDocumentRegistry(new[] { typeof(ClientTestDocument) }, cache);
|
||||||
}
|
}
|
||||||
|
|
@ -514,6 +546,7 @@ public sealed class CultMeshClientTests
|
||||||
}
|
}
|
||||||
if (message is CultNetOperationRequestMessage operation)
|
if (message is CultNetOperationRequestMessage operation)
|
||||||
{
|
{
|
||||||
|
operation.TargetRuntimeId.Should().Be("aetheria-daemon");
|
||||||
Operations.Add(operation);
|
Operations.Add(operation);
|
||||||
if (_failOperationBeforeResponse)
|
if (_failOperationBeforeResponse)
|
||||||
{
|
{
|
||||||
|
|
@ -539,7 +572,7 @@ public sealed class CultMeshClientTests
|
||||||
failure,
|
failure,
|
||||||
CultNetSchemaMessageSerialization.Options)),
|
CultNetSchemaMessageSerialization.Options)),
|
||||||
Diagnostics = new[] { failure.Message },
|
Diagnostics = new[] { failure.Message },
|
||||||
SourceRuntimeId = "provider-daemon"
|
SourceRuntimeId = _responseSourceRuntimeId
|
||||||
};
|
};
|
||||||
foreach (var handler in _operationResponses.ToArray()) handler(rejected);
|
foreach (var handler in _operationResponses.ToArray()) handler(rejected);
|
||||||
return;
|
return;
|
||||||
|
|
@ -562,7 +595,7 @@ public sealed class CultMeshClientTests
|
||||||
Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(
|
Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(
|
||||||
receipt,
|
receipt,
|
||||||
CultNetSchemaMessageSerialization.Options)),
|
CultNetSchemaMessageSerialization.Options)),
|
||||||
SourceRuntimeId = "provider-daemon"
|
SourceRuntimeId = _responseSourceRuntimeId
|
||||||
};
|
};
|
||||||
foreach (var handler in _operationResponses.ToArray()) handler(operationResponse);
|
foreach (var handler in _operationResponses.ToArray()) handler(operationResponse);
|
||||||
return;
|
return;
|
||||||
|
|
@ -596,6 +629,8 @@ public sealed class CultMeshClientTests
|
||||||
}
|
}
|
||||||
public void Dispose() { }
|
public void Dispose() { }
|
||||||
public void Fail(Exception error) => _failure.TrySetResult(error);
|
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)
|
private static async Task WaitUntilAsync(Func<bool> condition)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ namespace GameCult.Mesh.Tests;
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public sealed class CultMeshContentSessionTests
|
public sealed class CultMeshContentSessionTests
|
||||||
{
|
{
|
||||||
|
private const string RouteGeneration = "content-test-route-1";
|
||||||
private string _directory = null!;
|
private string _directory = null!;
|
||||||
|
|
||||||
[SetUp]
|
[SetUp]
|
||||||
|
|
@ -41,7 +42,13 @@ public sealed class CultMeshContentSessionTests
|
||||||
StringComparer.Ordinal);
|
StringComparer.Ordinal);
|
||||||
using var contentServer = new CultMeshTcpContentServer(
|
using var contentServer = new CultMeshTcpContentServer(
|
||||||
new TcpListener(IPAddress.Loopback, 0),
|
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 endpoint = $"{CultMeshTcpContentTransportConnector.Scheme}://127.0.0.1:{contentServer.LocalEndPoint.Port}";
|
||||||
var connector = new CultMeshTcpContentTransportConnector();
|
var connector = new CultMeshTcpContentTransportConnector();
|
||||||
using var discovery = new CultMeshDiscoveryService(new[] { new RouteSource(endpoint) });
|
using var discovery = new CultMeshDiscoveryService(new[] { new RouteSource(endpoint) });
|
||||||
|
|
@ -73,6 +80,34 @@ public sealed class CultMeshContentSessionTests
|
||||||
"all chunk requests borrow one identity-first content session");
|
"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() });
|
||||||
|
|
||||||
|
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]
|
[Test]
|
||||||
public async Task RudpContentSessionCarriesConcurrentChunkWindowWithoutPerFragmentSchedulerDelay()
|
public async Task RudpContentSessionCarriesConcurrentChunkWindowWithoutPerFragmentSchedulerDelay()
|
||||||
{
|
{
|
||||||
|
|
@ -91,6 +126,12 @@ public sealed class CultMeshContentSessionTests
|
||||||
MaxPendingReliablePackets = 8192
|
MaxPendingReliablePackets = 8192
|
||||||
});
|
});
|
||||||
using var contentServer = new CultMeshLegacyRudpContentServer(wireServer, providerCache);
|
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();
|
using var pumpCancellation = new CancellationTokenSource();
|
||||||
var pump = Task.Run(async () =>
|
var pump = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
|
|
@ -196,8 +237,14 @@ public sealed class CultMeshContentSessionTests
|
||||||
"Aetheria",
|
"Aetheria",
|
||||||
CultMeshVerseAuthorityModel.OperatorCluster,
|
CultMeshVerseAuthorityModel.OperatorCluster,
|
||||||
new CultMeshVerseCompatibility("cultmesh.v1", "rules"),
|
new CultMeshVerseCompatibility("cultmesh.v1", "rules"),
|
||||||
new[] { _endpoint },
|
authorityRoutes: new[]
|
||||||
new[] { "aetheria.daemon" }),
|
{
|
||||||
|
new CultMeshAuthorityRoute(
|
||||||
|
"aetheria.daemon",
|
||||||
|
_endpoint,
|
||||||
|
new[] { CultMeshProtocols.Content.Value },
|
||||||
|
generation: RouteGeneration)
|
||||||
|
}),
|
||||||
SourceId,
|
SourceId,
|
||||||
now,
|
now,
|
||||||
now.AddMinutes(1),
|
now.AddMinutes(1),
|
||||||
|
|
@ -241,7 +288,7 @@ public sealed class CultMeshContentSessionTests
|
||||||
((Func<TMessage, ICultNetSchemaServerPeer, Task>)_handlers[typeof(TMessage)])(message, peer);
|
((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 LoopbackSchemaServer _server;
|
||||||
private readonly Dictionary<Type, List<Delegate>> _handlers = new();
|
private readonly Dictionary<Type, List<Delegate>> _handlers = new();
|
||||||
|
|
@ -270,6 +317,8 @@ public sealed class CultMeshContentSessionTests
|
||||||
handlers.Add(callback);
|
handlers.Add(callback);
|
||||||
}
|
}
|
||||||
public void Dispose() { }
|
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
|
public void Emit<T>(T message) where T : ICultNetSchemaMessage
|
||||||
{
|
{
|
||||||
if (!_handlers.TryGetValue(typeof(T), out var handlers)) return;
|
if (!_handlers.TryGetValue(typeof(T), out var handlers)) return;
|
||||||
|
|
|
||||||
|
|
@ -295,7 +295,7 @@ public sealed class CultMeshDiscoveryServiceTests
|
||||||
CultMeshVerseAuthorityModel.OperatorCluster,
|
CultMeshVerseAuthorityModel.OperatorCluster,
|
||||||
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
|
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
|
||||||
new[] { endpoint },
|
new[] { endpoint },
|
||||||
new[] { "odin:aetheria", "odin:games", "aetheria" }),
|
new[] { "aetheria" }),
|
||||||
sourceId,
|
sourceId,
|
||||||
clock.UtcNow,
|
clock.UtcNow,
|
||||||
clock.UtcNow + ttl,
|
clock.UtcNow + ttl,
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,81 @@ public sealed class CultMeshSessionManagerTests
|
||||||
fastClient.DisposeCount.Should().Be(0);
|
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://decoy:3076"),
|
||||||
|
new CultMeshAuthorityRoute("aetheria-daemon", "rudp://authority: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 });
|
||||||
|
|
||||||
|
var session = await manager.ConnectAsync(Target, CultMeshProtocols.Documents);
|
||||||
|
|
||||||
|
session.State.Path!.Endpoint.Should().Be("rudp://authority:3076");
|
||||||
|
attempts.Should().Equal("rudp://authority:3076");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Connect_RejectsRouteWhoseNativeIdentityDoesNotMatchTarget()
|
||||||
|
{
|
||||||
|
var clock = new ManualSessionClock();
|
||||||
|
using var discovery = Discovery(clock, () => new[] { "rudp://decoy:3076" });
|
||||||
|
var connector = new FakeConnector((_, _) =>
|
||||||
|
Task.FromResult<ICultNetSchemaClient>(new NativeMismatchClient()));
|
||||||
|
using var manager = new CultMeshSessionManager(discovery, new[] { connector });
|
||||||
|
|
||||||
|
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://decoy:3076" });
|
||||||
|
var connector = new FakeConnector((_, _) =>
|
||||||
|
Task.FromResult<ICultNetSchemaClient>(new HandshakeSchemaClient("decoy-daemon")));
|
||||||
|
using var manager = new CultMeshSessionManager(discovery, new[] { connector });
|
||||||
|
|
||||||
|
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://shared:3076" },
|
||||||
|
authorityRuntimeIds: new[] { "authority-a", "authority-b" });
|
||||||
|
|
||||||
|
action.Should().Throw<InvalidOperationException>().WithMessage("*ambiguous*");
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task Connect_CallerCancellationDoesNotCancelSharedAttempt()
|
public async Task Connect_CallerCancellationDoesNotCancelSharedAttempt()
|
||||||
{
|
{
|
||||||
|
|
@ -308,6 +383,30 @@ public sealed class CultMeshSessionManagerTests
|
||||||
CultMeshDiscoveryTrust.Signed);
|
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 sealed class FakeConnector : ICultMeshTransportConnector
|
||||||
{
|
{
|
||||||
private readonly Func<CultMeshTransportCandidate, CancellationToken, Task<ICultNetSchemaClient>> _connect;
|
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 TaskCompletionSource<Exception> _failure = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
private readonly List<Action<CultNetErrorMessage>> _handlers = new();
|
private readonly List<Action<CultNetErrorMessage>> _handlers = new();
|
||||||
|
|
@ -340,6 +439,8 @@ public sealed class CultMeshSessionManagerTests
|
||||||
_handlers.Add(message => callback((T)(object)message));
|
_handlers.Add(message => callback((T)(object)message));
|
||||||
}
|
}
|
||||||
public void Dispose() => Interlocked.Increment(ref _disposeCount);
|
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 Fail(Exception error) => _failure.TrySetResult(error);
|
||||||
public void Emit(CultNetErrorMessage message) { foreach (var handler in _handlers.ToArray()) handler(message); }
|
public void Emit(CultNetErrorMessage message) { foreach (var handler in _handlers.ToArray()) handler(message); }
|
||||||
}
|
}
|
||||||
|
|
@ -360,7 +461,46 @@ public sealed class CultMeshSessionManagerTests
|
||||||
public void Dispose() { }
|
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 readonly List<Action<CultNetSnapshotResponseRawMessage>> _handlers = new();
|
||||||
private int _disposeCount;
|
private int _disposeCount;
|
||||||
|
|
@ -385,9 +525,11 @@ public sealed class CultMeshSessionManagerTests
|
||||||
_handlers.Add(message => callback((T)(object)message));
|
_handlers.Add(message => callback((T)(object)message));
|
||||||
}
|
}
|
||||||
public void Dispose() => Interlocked.Increment(ref _disposeCount);
|
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();
|
private readonly List<Action<CultMeshPeerExchangeResponseMessage>> _handlers = new();
|
||||||
public bool Connected => true;
|
public bool Connected => true;
|
||||||
|
|
@ -413,9 +555,11 @@ public sealed class CultMeshSessionManagerTests
|
||||||
_handlers.Add(message => callback((T)(object)message));
|
_handlers.Add(message => callback((T)(object)message));
|
||||||
}
|
}
|
||||||
public void Dispose() { }
|
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 readonly List<Action<CultNetErrorMessage>> _handlers = new();
|
||||||
private int _disposeCount;
|
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 Emit(CultNetErrorMessage message) { foreach (var handler in _handlers.ToArray()) handler(message); }
|
||||||
public void Dispose() => Interlocked.Increment(ref _disposeCount);
|
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
|
private sealed class ManualSessionClock : ICultMeshClock
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
|
@ -15,10 +16,17 @@ namespace GameCult.Mesh.Tests;
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public sealed class CultMeshTransportModularityTests
|
public sealed class CultMeshTransportModularityTests
|
||||||
{
|
{
|
||||||
|
private const string RouteGeneration = "transport-modularity-route-1";
|
||||||
[Test]
|
[Test]
|
||||||
public async Task SchemaSessionPrefersTcpTierWithoutTouchingLegacyDatagrams()
|
public async Task SchemaSessionPrefersTcpTierWithoutTouchingLegacyDatagrams()
|
||||||
{
|
{
|
||||||
using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0));
|
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) =>
|
server.OnCultNet<CultNetHelloMessage>((message, peer) =>
|
||||||
{
|
{
|
||||||
peer.SendCultNet(new CultNetErrorMessage { Error = "tcp:" + message.RuntimeId });
|
peer.SendCultNet(new CultNetErrorMessage { Error = "tcp:" + message.RuntimeId });
|
||||||
|
|
@ -61,7 +69,15 @@ public sealed class CultMeshTransportModularityTests
|
||||||
using var content = new CultCache(CultMesh.CreateCultCacheDocumentRegistry(
|
using var content = new CultCache(CultMesh.CreateCultCacheDocumentRegistry(
|
||||||
typeof(CultMeshCdnArtifactManifest), typeof(CultMeshCdnArtifactChunk)));
|
typeof(CultMeshCdnArtifactManifest), typeof(CultMeshCdnArtifactChunk)));
|
||||||
await CultMeshCdn.PublishAsync(content, artifact);
|
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 tcpEndpoint = $"{CultMeshTcpContentTransportConnector.Scheme}://127.0.0.1:{server.LocalEndPoint.Port}";
|
||||||
var legacy = new RejectingLegacyContentConnector();
|
var legacy = new RejectingLegacyContentConnector();
|
||||||
using var discovery = new CultMeshDiscoveryService(new[]
|
using var discovery = new CultMeshDiscoveryService(new[]
|
||||||
|
|
@ -156,6 +172,26 @@ public sealed class CultMeshTransportModularityTests
|
||||||
.WithMessage("*No realtime state connectors are configured*");
|
.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() });
|
||||||
|
|
||||||
|
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 sealed class RouteSource : ICultMeshLookupSource
|
||||||
{
|
{
|
||||||
private readonly string[] _endpoints;
|
private readonly string[] _endpoints;
|
||||||
|
|
@ -176,8 +212,11 @@ public sealed class CultMeshTransportModularityTests
|
||||||
"Aetheria",
|
"Aetheria",
|
||||||
CultMeshVerseAuthorityModel.OperatorCluster,
|
CultMeshVerseAuthorityModel.OperatorCluster,
|
||||||
new CultMeshVerseCompatibility("cultmesh.v1", "rules"),
|
new CultMeshVerseCompatibility("cultmesh.v1", "rules"),
|
||||||
_endpoints,
|
authorityRoutes: _endpoints.Select(endpoint => new CultMeshAuthorityRoute(
|
||||||
new[] { "aetheria.daemon" }),
|
"aetheria.daemon",
|
||||||
|
endpoint,
|
||||||
|
protocolIds: null,
|
||||||
|
generation: RouteGeneration))),
|
||||||
SourceId,
|
SourceId,
|
||||||
now,
|
now,
|
||||||
now.AddMinutes(1),
|
now.AddMinutes(1),
|
||||||
|
|
@ -214,7 +253,7 @@ public sealed class CultMeshTransportModularityTests
|
||||||
candidate.Endpoint.StartsWith("rudp://", StringComparison.OrdinalIgnoreCase);
|
candidate.Endpoint.StartsWith("rudp://", StringComparison.OrdinalIgnoreCase);
|
||||||
public Task<ICultMeshContentTransport> ConnectAsync(
|
public Task<ICultMeshContentTransport> ConnectAsync(
|
||||||
CultMeshTransportCandidate candidate,
|
CultMeshTransportCandidate candidate,
|
||||||
CultMeshEndpointId endpointId,
|
CultMeshSessionTarget target,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref _connectCount);
|
Interlocked.Increment(ref _connectCount);
|
||||||
|
|
@ -232,24 +271,70 @@ public sealed class CultMeshTransportModularityTests
|
||||||
candidate.Endpoint.StartsWith("cultmesh-state+quic://", StringComparison.OrdinalIgnoreCase);
|
candidate.Endpoint.StartsWith("cultmesh-state+quic://", StringComparison.OrdinalIgnoreCase);
|
||||||
public Task<ICultMeshRealtimeTransport> ConnectAsync(
|
public Task<ICultMeshRealtimeTransport> ConnectAsync(
|
||||||
CultMeshTransportCandidate candidate,
|
CultMeshTransportCandidate candidate,
|
||||||
CultMeshEndpointId endpointId,
|
CultMeshSessionTarget target,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref _connectCount);
|
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 Queue<CultMeshRealtimeFrame> _frames = new();
|
||||||
private readonly SemaphoreSlim _available = new(0);
|
private readonly SemaphoreSlim _available = new(0);
|
||||||
private bool _disposed;
|
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 TransportId => "test-quic-state";
|
||||||
public string Endpoint { get; }
|
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)
|
public Task SendAsync(CultMeshRealtimeFrame frame, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (_disposed) throw new ObjectDisposedException(nameof(LoopbackRealtimeTransport));
|
if (_disposed) throw new ObjectDisposedException(nameof(LoopbackRealtimeTransport));
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,41 @@ namespace GameCult.Networking.Tests
|
||||||
Is.EqualTo(4));
|
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]
|
[Test]
|
||||||
public async Task RejectsWrongSchemaBeforeApplicationHandler()
|
public async Task RejectsWrongSchemaBeforeApplicationHandler()
|
||||||
{
|
{
|
||||||
|
|
@ -157,7 +192,8 @@ namespace GameCult.Networking.Tests
|
||||||
Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(
|
Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(
|
||||||
value,
|
value,
|
||||||
CultNetSchemaMessageSerialization.Options)),
|
CultNetSchemaMessageSerialization.Options)),
|
||||||
SourceRuntimeId = "sample.browser"
|
SourceRuntimeId = "sample.browser",
|
||||||
|
TargetRuntimeId = "sample.provider"
|
||||||
};
|
};
|
||||||
|
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
|
|
|
||||||
|
|
@ -3627,8 +3627,19 @@ namespace GameCult.Networking.Tests
|
||||||
"Aetheria",
|
"Aetheria",
|
||||||
CultMeshVerseAuthorityModel.OperatorCluster,
|
CultMeshVerseAuthorityModel.OperatorCluster,
|
||||||
new CultMeshVerseCompatibility("cultmesh.v0", vanillaRules),
|
new CultMeshVerseCompatibility("cultmesh.v0", vanillaRules),
|
||||||
discoveryEndpoints: ["cultmesh://aetheria.example.test:3075"],
|
authorityRoutes:
|
||||||
authorityRuntimeIds: ["gc-us-east", "gc-eu-west"]);
|
[
|
||||||
|
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(
|
var modded = new CultMeshVerseDescriptor(
|
||||||
"aetheria-skylands",
|
"aetheria-skylands",
|
||||||
"Aetheria: Skylands",
|
"Aetheria: Skylands",
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,12 @@ static async Task<Result> MeasureCultMeshRudpAsync(byte[] payload)
|
||||||
MaxPendingReliablePackets = 8192
|
MaxPendingReliablePackets = 8192
|
||||||
});
|
});
|
||||||
using var contentServer = new CultMeshLegacyRudpContentServer(wireServer, providerCache);
|
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();
|
using var pumpCancellation = new CancellationTokenSource();
|
||||||
var pump = Task.Run(async () =>
|
var pump = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
|
|
@ -359,7 +365,7 @@ static async Task<Result> MeasureCultMeshRudpAsync(byte[] payload)
|
||||||
[new CultMeshLegacyRudpContentTransportConnector(
|
[new CultMeshLegacyRudpContentTransportConnector(
|
||||||
new CultMeshLegacyRudpContentTransportOptions { ResponseTimeout = TimeSpan.FromMinutes(2) })]);
|
new CultMeshLegacyRudpContentTransportOptions { ResponseTimeout = TimeSpan.FromMinutes(2) })]);
|
||||||
var provider = new CultMeshSessionContentProvider(
|
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"));
|
var directory = Path.Combine(Path.GetTempPath(), "cultmesh-transport-parity", Guid.NewGuid().ToString("N"));
|
||||||
using var transferState = new CultCache(CultMesh.CreateCultCacheDocumentRegistry(
|
using var transferState = new CultCache(CultMesh.CreateCultCacheDocumentRegistry(
|
||||||
typeof(CultMeshContentTransferStateDocument)));
|
typeof(CultMeshContentTransferStateDocument)));
|
||||||
|
|
@ -392,7 +398,14 @@ static async Task<Result> MeasureCultMeshTcpAsync(byte[] payload)
|
||||||
await CultMeshCdn.PublishAsync(providerCache, artifact);
|
await CultMeshCdn.PublishAsync(providerCache, artifact);
|
||||||
|
|
||||||
using var contentServer = new CultMeshTcpContentServer(
|
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}";
|
var endpoint = $"{CultMeshTcpContentTransportConnector.Scheme}://127.0.0.1:{contentServer.LocalEndPoint.Port}";
|
||||||
using var discovery = new CultMeshDiscoveryService([new RouteSource(endpoint)]);
|
using var discovery = new CultMeshDiscoveryService([new RouteSource(endpoint)]);
|
||||||
using var sessions = new CultMeshSessionManager(
|
using var sessions = new CultMeshSessionManager(
|
||||||
|
|
@ -400,7 +413,7 @@ static async Task<Result> MeasureCultMeshTcpAsync(byte[] payload)
|
||||||
Array.Empty<ICultMeshTransportConnector>(),
|
Array.Empty<ICultMeshTransportConnector>(),
|
||||||
[new CultMeshTcpContentTransportConnector()]);
|
[new CultMeshTcpContentTransportConnector()]);
|
||||||
var provider = new CultMeshSessionContentProvider(
|
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"));
|
var directory = Path.Combine(Path.GetTempPath(), "cultmesh-transport-parity", Guid.NewGuid().ToString("N"));
|
||||||
using var transferState = new CultCache(CultMesh.CreateCultCacheDocumentRegistry(
|
using var transferState = new CultCache(CultMesh.CreateCultCacheDocumentRegistry(
|
||||||
typeof(CultMeshContentTransferStateDocument)));
|
typeof(CultMeshContentTransferStateDocument)));
|
||||||
|
|
@ -479,8 +492,14 @@ internal sealed class RouteSource(string endpoint) : ICultMeshLookupSource
|
||||||
new(
|
new(
|
||||||
new CultMeshVerseDescriptor(
|
new CultMeshVerseDescriptor(
|
||||||
"parity", "Transport parity", CultMeshVerseAuthorityModel.OperatorCluster,
|
"parity", "Transport parity", CultMeshVerseAuthorityModel.OperatorCluster,
|
||||||
new CultMeshVerseCompatibility("cultmesh.v1", "parity"), [endpoint],
|
new CultMeshVerseCompatibility("cultmesh.v1", "parity"),
|
||||||
["service:parity.provider"]),
|
authorityRoutes:
|
||||||
|
[
|
||||||
|
new CultMeshAuthorityRoute(
|
||||||
|
"service:parity.provider",
|
||||||
|
endpoint,
|
||||||
|
generation: "parity-route-1")
|
||||||
|
]),
|
||||||
SourceId, now, now.AddMinutes(1), CultMeshDiscoveryTrust.Signed)
|
SourceId, now, now.AddMinutes(1), CultMeshDiscoveryTrust.Signed)
|
||||||
];
|
];
|
||||||
return Task.FromResult(observations);
|
return Task.FromResult(observations);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue