CultLib/src/GameCult.Mesh/CultMeshBodyDemand.cs
Meta Vens 568e8e356f CultNet selection cut1, commit 2 fix batch: door, shard-via-evaluator, Mesh v0 refusal, dead Reconcile code, mutation harness crash handling
Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22):
empty/blank schemas-keys still reached the evaluator, the shard snapshot ran
its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only
selection terms, the Mesh one-pass read could return the wrong schema's
record at a shared key, two decode rules for a foreign schema id disagreed,
dead identity-comparison code from the _projectRecord removal was still live,
and a disposed-socket race could abort a mutation run instead of recording
NO VERDICT. This is Self's fix batch for all of it.

- CultNetSelection.Validate now refuses a blank/whitespace entry inside
  schemas or keys, not just an empty array. CultNetSelectionEvaluator no
  longer treats [] as "no filter" (the door already refuses it before
  evaluation). CultNetV0SelectionLowering is the one place that lowers an
  empty or all-blank v0 list to null; CreateRawSnapshotResponse and
  CreateShardSnapshotResponse both route through it, and CultMesh's own
  Clean() now delegates to it instead of duplicating the rule.
- CreateShardSnapshotResponse no longer carries a private loop or its own
  schema/key matching: it builds a CultNetSelection and answers through
  CultNetDocumentRegistry.SelectPage (the same evaluator path as the
  non-shard snapshot), with the shard's own membership check as a row
  filter. Binding-alias expansion now runs on both paths identically.
- ResolveDefaultSelection decides "no schema filter" from the cleaned
  recordKeys, not the raw array length. OverlaySelection stops deciding what
  a missing override means: any explicit schemaIds/recordKeys override
  replaces the default selection wholesale; no override keeps the default
  untouched.
- CultMeshSnapshotRequestOptions.Selection is now checked by
  EnsureV0Compatible before every send: fields, cites, cited, limit, cursor,
  descending, and any non-default projection are refused typed
  (CultNetSelectionInvalidException), never silently dropped from the v0
  wire message. Mesh sending v1 is FU-Mesh-v1, not this cut.
- CultMeshHotBodySubscription.Selection returns a defensive copy on every
  read so its arrays can't be mutated out from under the stored selection.
- ReadDocumentFromSnapshotResponse now searches candidates at one recordKey
  in one order - exact schema, then alias, then a foreign schema id decoded
  from the payload - instead of one OR'd condition where a case-insensitive
  or structurally-lucky candidate could win first. The foreign-id decode
  reads the payload's embedded schema version with the registry's shared
  CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against
  the caller's own descriptor directly, rather than resolving to "whichever
  registered type owns this schema string" - that resolution is ambiguous
  when two document types alias the same schema id (MeshNoteDocument and
  MeshNoteAliasDocument in the existing fixtures), which is what broke on
  the first pass; TryResolveDescriptorByPayloadSchema stays for its existing
  caller (ResolveDescriptorForRawDocument) with a note on when not to use it.
  DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode
  (RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in
  favour of the shared one.
- CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity
  comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key
  throw in CreateProjectedSnapshot, are deleted - they can no longer fire
  now that a projection's dictionary key is always the record's own key.
  A new test flips authorizeRecord per key while the whole request stays
  authorized (so DemandActive never toggles) and proves both halves of the
  remaining diff: the removal of a now-unauthorized key and the add loop
  picking up a newly-authorized one.
- Every NetworkingTests.cs test that starts an IsBackground poll thread now
  joins it before its server disposes (about 20 tests; one already did).
- scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no
  longer throws the whole run away under PowerShell 5.1's
  $ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in
  a terminating NativeCommandError); Run-Tests catches it and the entry loop
  records NO VERDICT instead of aborting every remaining entry.
- Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On
  UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard
  snapshot paths agree, including shard-membership exclusion of a document
  outside the shard's key prefix and a binding schema-id override matching
  on both paths.
- Mutation entries updated/added to match: the stale NET-D4-FourthMatcher
  entry (its anchor was the private loop this batch deleted, not refactored)
  is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's
  header miscount ("two of three" loosening entries when there are only
  two) is corrected and both previously-surviving loosening mutants
  (MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures;
  scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering
  Reconcile's remaining two mutants.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:13:15 +02:00

443 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Networking;
#nullable enable
namespace GameCult.Mesh
{
/// <summary>
/// Declares one logical body demand. CultMesh owns locality detection and transport selection;
/// consumers name the body and the planes they can actually read.
/// </summary>
public sealed class CultMeshLiveBodySubscription
{
public CultMeshLiveBodySubscription(
string subscriptionId,
string consumerRuntimeId,
string bodyId)
{
SubscriptionId = Require(subscriptionId, nameof(subscriptionId));
ConsumerRuntimeId = Require(consumerRuntimeId, nameof(consumerRuntimeId));
BodyId = Require(bodyId, nameof(bodyId));
}
public string SubscriptionId { get; }
public string ConsumerRuntimeId { get; }
public string BodyId { get; }
public string PublicationRecordKey =>
CultMeshBodyPublicationDocument.CreateLatestRecordKey(BodyId).Value;
private static string Require(string value, string parameterName) =>
string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value is required.", parameterName)
: value;
}
/// <summary>
/// One ephemeral body subscription. Publication metadata remains reactive control state while
/// body bytes stay on the negotiated body plane.
/// </summary>
public sealed class CultMeshLiveBody : IDisposable
{
private readonly CultNetDatabaseSubscriptionClient _owner;
private readonly CultMeshBodyPublicationResolver _resolver;
private readonly CultMeshLiveBodySubscription _subscription;
private readonly object _gate = new();
private CultMeshBodyPublicationDocument? _current;
private bool _hasValue;
private bool _disposed;
internal CultMeshLiveBody(
CultNetDatabaseSubscriptionClient owner,
CultMeshBodyPublicationResolver resolver,
CultMeshLiveBodySubscription subscription)
{
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_subscription = subscription ?? throw new ArgumentNullException(nameof(subscription));
_owner.Changed += OnChanged;
}
/// <summary>Raised when the provider publishes a new generation descriptor.</summary>
public event Action<CultMeshBodyPublicationDocument>? Changed;
/// <summary>Raised when the provider withdraws the logical body.</summary>
public event Action? Removed;
public string BodyId => _subscription.BodyId;
public bool HasValue
{
get { lock (_gate) return _hasValue; }
}
public CultMeshBodyPublicationDocument Current
{
get
{
lock (_gate)
return _hasValue
? _current!
: throw new InvalidOperationException(
$"Live body '{_subscription.BodyId}' is not currently published.");
}
}
/// <summary>
/// Opens the current generation read-only using the fastest valid advertised representation.
/// The caller owns the returned lease and should dispose it before opening a later generation.
/// </summary>
public CultMeshBodyNegotiationResult OpenCurrentReadOnly(DateTimeOffset? nowUtc = null)
{
CultMeshBodyPublicationDocument publication;
lock (_gate)
{
if (_disposed) throw new ObjectDisposedException(nameof(CultMeshLiveBody));
if (!_hasValue)
throw new InvalidOperationException(
$"Live body '{_subscription.BodyId}' is not currently published.");
publication = _current!;
}
return _resolver.NegotiateReadOnly(publication, new CultMeshBodyValidationRequest
{
BodyId = publication.BodyId,
SchemaId = publication.SchemaId,
LayoutVersion = publication.LayoutVersion,
ProducerEpoch = publication.ProducerEpoch,
Sequence = publication.Sequence,
Capacity = publication.Capacity,
AccessMode = CultMeshBodyAccessMode.ReadOnly,
NowUtc = nowUtc ?? DateTimeOffset.UtcNow
});
}
internal void Initialize(CultMeshBodyPublicationDocument publication)
{
CultMeshBodyPublicationValidator.Validate(publication, expectedBodyId: _subscription.BodyId);
lock (_gate)
{
if (_disposed || _hasValue) return;
_current = publication;
_hasValue = true;
}
}
public void Dispose()
{
lock (_gate)
{
if (_disposed) return;
_disposed = true;
_current = null;
_hasValue = false;
}
_owner.Changed -= OnChanged;
_owner.Unsubscribe(_subscription.SubscriptionId);
}
private void OnChanged(CultNetReplicatedDocumentChange change)
{
if (!string.Equals(change.SubscriptionId, _subscription.SubscriptionId, StringComparison.Ordinal) ||
!string.Equals(change.RecordKey, _subscription.PublicationRecordKey, StringComparison.Ordinal))
return;
if (string.Equals(change.ChangeKind, "removed", StringComparison.Ordinal))
{
lock (_gate)
{
if (_disposed) return;
_current = null;
_hasValue = false;
}
Removed?.Invoke();
return;
}
if (change.Document is not CultMeshBodyPublicationDocument publication)
return;
CultMeshBodyPublicationValidator.Validate(publication, expectedBodyId: _subscription.BodyId);
lock (_gate)
{
if (_disposed) return;
_current = publication;
_hasValue = true;
}
Changed?.Invoke(publication);
}
}
/// <summary>
/// One exact hot-body subscription. The view record, latest-publication record, consumer body
/// demand, and locally supported planes are one contract so callers cannot accidentally stream
/// body bytes through retained document snapshots.
/// </summary>
public sealed class CultMeshHotBodySubscription
{
public CultMeshHotBodySubscription(
string subscriptionId,
string consumerRuntimeId,
string viewRecordKey,
string viewSchemaId,
string bodyId,
IEnumerable<string>? additionalRecordKeys = null,
IEnumerable<string>? additionalSchemaIds = null)
{
SubscriptionId = Require(subscriptionId, nameof(subscriptionId));
ConsumerRuntimeId = Require(consumerRuntimeId, nameof(consumerRuntimeId));
BodyId = Require(bodyId, nameof(bodyId));
_selection = new CultNetSelection
{
Keys = new[]
{
Require(viewRecordKey, nameof(viewRecordKey)),
CultMeshBodyPublicationDocument.CreateLatestRecordKey(bodyId).Value
}
.Concat(additionalRecordKeys ?? Array.Empty<string>())
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray(),
Schemas = new[]
{
Require(viewSchemaId, nameof(viewSchemaId)),
CultMeshBodyPublicationSchemaVersions.Publication
}
.Concat(additionalSchemaIds ?? Array.Empty<string>())
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray()
};
}
public string SubscriptionId { get; }
public string ConsumerRuntimeId { get; }
public string BodyId { get; }
private readonly CultNetSelection _selection;
/// <summary>
/// The view record, the body's latest-publication record, and any additional keys/schemas, as
/// one selection. Returns a fresh copy on every read: <see cref="CultNetSelection"/>'s arrays
/// are wire-mutable by design, and this contract does not let a caller mutate them out from
/// under it (docs/cultnet-selection-cut.md, S2-8).
/// </summary>
public CultNetSelection Selection => new()
{
Schemas = (string[]?)_selection.Schemas?.Clone(),
Keys = (string[]?)_selection.Keys?.Clone()
};
private static string Require(string value, string parameterName) =>
string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value is required.", parameterName)
: value;
}
/// <summary>Derived transport work for one logical hot body.</summary>
public sealed class CultMeshBodyDemandPlan
{
internal CultMeshBodyDemandPlan(
string bodyId,
long generation,
IReadOnlyList<CultMeshBodyConsumerRoute> consumers)
{
BodyId = bodyId;
Generation = generation;
Consumers = consumers;
RequiresSharedMemory = consumers.Any(value => value.Transport == CultMeshBodyTransportKind.SharedMemory);
RequiresSharedFileMapping = consumers.Any(value => value.Transport == CultMeshBodyTransportKind.SharedFileMapping);
RequiresNetwork = consumers.Any(value => value.Transport == CultMeshBodyTransportKind.Network);
}
public string BodyId { get; }
/// <summary>
/// Monotone generation of the exact routed consumer set. Publishers must rotate any
/// retained capability before writing after this value changes.
/// </summary>
public long Generation { get; }
public bool HasConsumers => Consumers.Count > 0;
public bool RequiresSharedMemory { get; }
public bool RequiresSharedFileMapping { get; }
public bool RequiresNetwork { get; }
public IReadOnlyList<CultMeshBodyConsumerRoute> Consumers { get; }
}
/// <summary>One consumer's owner-derived route for one logical body.</summary>
public sealed class CultMeshBodyConsumerRoute
{
internal CultMeshBodyConsumerRoute(
string consumerRuntimeId,
string subscriptionId,
CultMeshBodyTransportKind transport)
{
ConsumerRuntimeId = consumerRuntimeId;
SubscriptionId = subscriptionId;
Transport = transport;
}
public string ConsumerRuntimeId { get; }
public string SubscriptionId { get; }
public CultMeshBodyTransportKind Transport { get; }
}
/// <summary>
/// Owns the live projection from exact state subscriptions to required body transport planes.
/// A same-machine claim is transport evidence only; shared memory is selected only when both
/// the server peer and consumer capability agree.
/// </summary>
public sealed class CultMeshBodyDemandTracker : IDisposable
{
private readonly CultNetDatabaseSubscriptionServer? _subscriptions;
private readonly object _gate = new();
private readonly Dictionary<string, CultNetDatabaseSubscriptionDemand> _demands =
new(StringComparer.Ordinal);
private readonly Dictionary<string, long> _bodyGenerations =
new(StringComparer.Ordinal);
private bool _disposed;
public CultMeshBodyDemandTracker(CultNetDatabaseSubscriptionServer subscriptions)
{
_subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions));
_subscriptions.DemandChanged += OnDemandChanged;
}
/// <summary>Creates a tracker fed explicitly by a subscription/session control plane.</summary>
public CultMeshBodyDemandTracker()
{
}
public CultMeshBodyDemandPlan Plan(string bodyId)
{
if (string.IsNullOrWhiteSpace(bodyId)) throw new ArgumentException("Body identity is required.", nameof(bodyId));
lock (_gate)
{
ThrowIfDisposed();
var routes = _demands.Values
.Where(value => value.BodyIds.Contains(bodyId, StringComparer.Ordinal))
.Select(value => Route(value))
.Where(value => value != null)
.Cast<CultMeshBodyConsumerRoute>()
.OrderBy(value => value.ConsumerRuntimeId, StringComparer.Ordinal)
.ThenBy(value => value.SubscriptionId, StringComparer.Ordinal)
.ToArray();
return new CultMeshBodyDemandPlan(
bodyId,
_bodyGenerations.TryGetValue(bodyId, out var generation) ? generation : 0,
routes);
}
}
public void Dispose()
{
lock (_gate)
{
if (_disposed) return;
_disposed = true;
if (_subscriptions != null) _subscriptions.DemandChanged -= OnDemandChanged;
_demands.Clear();
_bodyGenerations.Clear();
}
}
/// <summary>Applies one active or withdrawn typed body-demand observation.</summary>
public void Observe(CultNetDatabaseSubscriptionDemand demand)
{
if (demand == null) throw new ArgumentNullException(nameof(demand));
if (demand.BodyIds.Count == 0) return;
var key = demand.ConsumerRuntimeId + "\u001f" + demand.SubscriptionId;
lock (_gate)
{
if (_disposed) return;
_demands.TryGetValue(key, out var previous);
var affectedBodyIds = (previous?.BodyIds ?? Array.Empty<string>())
.Concat(demand.BodyIds)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray();
var before = affectedBodyIds.ToDictionary(
bodyId => bodyId,
RoutedConsumerSignature,
StringComparer.Ordinal);
if (demand.Active) _demands[key] = demand;
else _demands.Remove(key);
foreach (var bodyId in affectedBodyIds)
{
var after = RoutedConsumerSignature(bodyId);
if (string.Equals(before[bodyId], after, StringComparison.Ordinal)) continue;
_bodyGenerations[bodyId] = checked(
(_bodyGenerations.TryGetValue(bodyId, out var generation) ? generation : 0) + 1);
}
}
}
/// <summary>
/// Executes one synchronous capability commit only while the exact routed consumer
/// generation remains current. Demand transitions serialize against the callback, so
/// either this work linearizes before the transition or it is rejected without running.
/// </summary>
public bool TryExecuteAtGeneration(string bodyId, long generation, Action action)
{
if (string.IsNullOrWhiteSpace(bodyId)) throw new ArgumentException("Body identity is required.", nameof(bodyId));
if (generation < 0) throw new ArgumentOutOfRangeException(nameof(generation));
if (action == null) throw new ArgumentNullException(nameof(action));
lock (_gate)
{
ThrowIfDisposed();
var current = _bodyGenerations.TryGetValue(bodyId, out var value) ? value : 0;
if (current != generation) return false;
action();
return true;
}
}
private string RoutedConsumerSignature(string bodyId) => string.Join(
"\u001e",
_demands.Values
.Where(value => value.BodyIds.Contains(bodyId, StringComparer.Ordinal))
.Select(Route)
.Where(value => value != null)
.Cast<CultMeshBodyConsumerRoute>()
.OrderBy(value => value.ConsumerRuntimeId, StringComparer.Ordinal)
.ThenBy(value => value.SubscriptionId, StringComparer.Ordinal)
.Select(value => string.Join(
"\u001f",
value.ConsumerRuntimeId,
value.SubscriptionId,
value.Transport.ToString())));
private void OnDemandChanged(CultNetDatabaseSubscriptionDemand demand) => Observe(demand);
private static CultMeshBodyConsumerRoute? Route(CultNetDatabaseSubscriptionDemand demand)
{
var supported = demand.SupportedBodyTransports;
if (demand.SameMachine && Supports(supported, CultMeshBodyTransportKind.SharedMemory))
return new CultMeshBodyConsumerRoute(
demand.ConsumerRuntimeId,
demand.SubscriptionId,
CultMeshBodyTransportKind.SharedMemory);
if (demand.SameMachine && Supports(supported, CultMeshBodyTransportKind.SharedFileMapping))
return new CultMeshBodyConsumerRoute(
demand.ConsumerRuntimeId,
demand.SubscriptionId,
CultMeshBodyTransportKind.SharedFileMapping);
if (Supports(supported, CultMeshBodyTransportKind.Network))
return new CultMeshBodyConsumerRoute(
demand.ConsumerRuntimeId,
demand.SubscriptionId,
CultMeshBodyTransportKind.Network);
return null;
}
private static bool Supports(
IReadOnlyList<string> supported,
CultMeshBodyTransportKind transport) =>
supported.Any(value => string.Equals(value, transport.ToString(), StringComparison.OrdinalIgnoreCase));
private void ThrowIfDisposed()
{
if (_disposed) throw new ObjectDisposedException(nameof(CultMeshBodyDemandTracker));
}
}
}