Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions src/MeshWeaver.Data.Contract/EntityStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,34 @@ public virtual bool Equals(EntityStore? other)
&& value.Equals(x.Value));
}

/// <summary>Returns a hash code derived from the contained collections.</summary>
/// <returns>The hash code.</returns>
/// <summary>
/// Hash derived from the collection NAMES and their sizes — deliberately NOT from the
/// contained instances.
/// <para>
/// 🚨 The previous version aggregated <c>InstanceCollection.GetHashCode()</c>, which walked
/// every stored instance; because a stored instance can transitively hold this very store
/// (<c>EntityStore → ChangeItem → InstanceCollection → EntityStore</c>), that recursion was
/// unbounded and killed pods with an uncatchable StackOverflowException (#2170/#2171).
/// </para>
/// <para>
/// It also threw <see cref="InvalidOperationException"/> ("Sequence contains no elements") for
/// an EMPTY store: <c>Aggregate</c> was called without a seed. An empty store now hashes
/// normally.
/// </para>
/// <para>
/// Contract-consistent with <see cref="Equals(EntityStore)"/>: equal stores hold the same
/// collection names, and equal <see cref="InstanceCollection"/>s hold the same number of
/// instances, so equal stores hash equal. The XOR is order-independent.
/// </para>
/// </summary>
/// <returns>A bounded, cycle-free hash code.</returns>
public override int GetHashCode()
=> Collections.Values
.Select(x => x.GetHashCode())
.Aggregate((x, y) => x ^ y);
{
var hash = 0;
foreach (var kvp in Collections)
hash ^= HashCode.Combine(kvp.Key, kvp.Value.Instances.Count);
return HashCode.Combine(hash, Collections.Count);
}


internal IEnumerable<EntityUpdate> ComputeChanges(string collection, InstanceCollection updated)
Expand Down
35 changes: 31 additions & 4 deletions src/MeshWeaver.Data.Contract/InstanceCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,35 @@ public virtual bool Equals(InstanceCollection? other)
);
}

/// <summary>Returns a hash code derived from the contained instances.</summary>
/// <returns>The hash code.</returns>
public override int GetHashCode() =>
Instances.Values.Select(x => x.GetHashCode()).Aggregate(0, (x, y) => x ^ y);
/// <summary>
/// Hash derived from the instance KEYS and the count — deliberately NOT from the instance
/// VALUES.
/// <para>
/// 🚨 <see cref="Instances"/> holds ARBITRARY user objects, and in a running mesh those
/// objects reach back into the store that contains this collection
/// (<c>InstanceCollection → EntityStore → ChangeItem → InstanceCollection</c>). Hashing the
/// values therefore walked an unbounded object graph and died on an uncatchable
/// StackOverflowException, terminating the pod (#2170/#2171). It was also O(entire graph) on
/// a routing hot path.
/// </para>
/// <para>
/// Keys are safe by construction — <see cref="Instances"/> already hashed every one of them to
/// store it — and they satisfy the hash/equals contract against the value-based
/// <see cref="Equals(InstanceCollection)"/>: equal collections hold the same keys in the same
/// number, hence the same hash. The XOR is order-independent, so it does not depend on
/// <see cref="ImmutableDictionary{TKey,TValue}"/> enumeration order. Note
/// <see cref="CollectionName"/> is excluded because <c>Equals</c> ignores it — including it
/// would break the contract.
/// </para>
/// </summary>
/// <returns>A bounded, cycle-free hash code.</returns>
public override int GetHashCode()
{
// foreach over the struct enumerator: no LINQ delegate/iterator allocation, unlike the
// Select+Aggregate this replaced.
var keyHash = 0;
foreach (var kvp in Instances)
keyHash ^= kvp.Key.GetHashCode();
return HashCode.Combine(keyHash, Instances.Count);
}
}
21 changes: 21 additions & 0 deletions src/MeshWeaver.Data/ChangeItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,25 @@ long Version
: this(Value, null, StreamId, ChangeType.Full, Version, null)
{ }

/// <summary>
/// Hash derived from the change's own identity fields — deliberately NOT from
/// <see cref="Value"/>.
/// <para>
/// 🚨 <see cref="Value"/> is the whole synchronized state (typically an
/// <see cref="EntityStore"/>, or the stream state of an arbitrary <typeparamref name="TStream"/>).
/// The record-synthesized hash walked it, which made <c>ChangeItem</c> the third layer of the
/// unbounded <c>InstanceCollection → EntityStore → ChangeItem → InstanceCollection</c> hash
/// recursion that killed pods with an uncatchable StackOverflowException (#2170/#2171), and
/// made a single hash O(the entire object graph) on a hot path.
/// </para>
/// <para>
/// The four fields used are all part of the synthesized <c>Equals</c>, so the hash/equals
/// contract holds: equal change items agree on them and therefore hash equal. Value-based
/// <c>Equals</c> is intentionally left untouched — the <c>SetCurrent</c> dedup that suppresses
/// re-emission of an identical re-synced store depends on it.
/// </para>
/// </summary>
/// <returns>A bounded, cycle-free hash code.</returns>
public override int GetHashCode() =>
HashCode.Combine(StreamId, ChangedBy, ChangeType, Version);
}
78 changes: 78 additions & 0 deletions src/MeshWeaver.Data/Serialization/SynchronizationStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using MeshWeaver.Messaging;
Expand Down Expand Up @@ -1611,6 +1612,37 @@ private void RequestFreshSnapshot()

internal StreamConfiguration<TStream> Configuration { get; }

/// <summary>
/// 🚨 REFERENCE identity — deliberately NOT the record-synthesized structural equality.
/// <para>
/// A stream is a LIVE object, not a value: it owns a <see cref="ReplaySubject{T}"/>, mutable
/// <c>current</c> state, a hosted hub, subscriptions and disposal state. Two distinct stream
/// instances are never "the same stream", so reference identity IS the correct semantics
/// (the synthesized version already behaved this way in practice — each instance carries its
/// own <see cref="Store"/> subject, compared by reference).
/// </para>
/// <para>
/// It is also the only SAFE semantics. The synthesized <c>GetHashCode</c>/<c>Equals</c> walk
/// every instance field, including <see cref="Configuration"/> — and
/// <see cref="StreamConfiguration{TStream}.Stream"/> points straight back here. That closed a
/// cycle <c>SynchronizationStream.GetHashCode → StreamConfiguration.GetHashCode →
/// EqualityComparer&lt;ISynchronizationStream&lt;TStream&gt;&gt;.Default.GetHashCode(Stream) →
/// …</c> with no base case, so ANY hash of a stream instance (a dictionary key, a log scope,
/// a HashSet) recursed until the process died on an uncatchable StackOverflowException
/// (#2163/#2164/#2172/#2173/#2174/#2175 — repeated pod kills in prod).
/// </para>
/// </summary>
/// <param name="other">The stream to compare against.</param>
/// <returns>True only when <paramref name="other"/> is this very instance.</returns>
public virtual bool Equals(SynchronizationStream<TStream>? other) => ReferenceEquals(this, other);

/// <summary>
/// Reference-identity hash — see <see cref="Equals(SynchronizationStream{TStream})"/> for why
/// structural hashing of a stream is both wrong and fatal.
/// </summary>
/// <returns>The runtime identity hash of this instance.</returns>
public override int GetHashCode() => RuntimeHelpers.GetHashCode(this);


/// <summary>
/// Disposes the stream: completes the underlying store and tears down the hosted hub.
Expand Down Expand Up @@ -1873,4 +1905,50 @@ public StreamConfiguration<TStream> WithDeferredInitialization(bool deferred = t
public StreamConfiguration<TStream> WithDeferredInitialization(
string gateName, Predicate<IMessageDelivery> allowDuringInit)
=> this with { DeferredInitialization = true, DeferredGateName = gateName, DeferredGatePredicate = allowDuringInit };

/// <summary>
/// 🚨 Compares this configuration's OWN values; the <see cref="Stream"/> back-pointer
/// participates BY REFERENCE and is never traversed structurally.
/// <para>
/// <see cref="Stream"/> points back at the stream that OWNS this configuration
/// (<c>new StreamConfiguration&lt;TStream&gt;(this)</c>), so letting the record-synthesized
/// members walk it closes an unbounded cycle through
/// <see cref="SynchronizationStream{TStream}"/> — the StackOverflow that killed portal pods
/// (#2163/#2164/#2172/#2173/#2174/#2175). The owning stream is part of this configuration's
/// IDENTITY, not of its value, which is exactly what reference comparison expresses.
/// </para>
/// </summary>
/// <param name="other">The configuration to compare against.</param>
/// <returns>True if both configurations belong to the same stream and carry the same settings.</returns>
public virtual bool Equals(StreamConfiguration<TStream>? other) =>
other is not null
&& (ReferenceEquals(this, other)
|| (ReferenceEquals(Stream, other.Stream)
&& ClientId == other.ClientId
&& Equals(Subscriber, other.Subscriber)
&& NullReturn == other.NullReturn
&& RunsAsInfrastructure == other.RunsAsInfrastructure
&& DeferredInitialization == other.DeferredInitialization
&& DeferredGateName == other.DeferredGateName
&& Equals(Initialization, other.Initialization)
&& Equals(ObservableInitialization, other.ObservableInitialization)
&& Equals(ExceptionCallback, other.ExceptionCallback)
&& Equals(DeferredGatePredicate, other.DeferredGatePredicate)));

/// <summary>
/// Hash over this configuration's own settings plus the owning stream's REFERENCE identity —
/// never a structural walk of <see cref="Stream"/>. See
/// <see cref="Equals(StreamConfiguration{TStream})"/>. The delegate-valued settings are
/// deliberately omitted (equal configurations still hash equal, which is all the contract asks).
/// </summary>
/// <returns>A bounded, cycle-free hash code.</returns>
public override int GetHashCode() =>
HashCode.Combine(
RuntimeHelpers.GetHashCode(Stream),
ClientId,
Subscriber,
NullReturn,
RunsAsInfrastructure,
DeferredInitialization,
DeferredGateName);
}
11 changes: 6 additions & 5 deletions src/MeshWeaver.Data/Workspace.cs
Original file line number Diff line number Diff line change
Expand Up @@ -439,15 +439,16 @@ WorkspaceReference<EntityStore> reference
// workspace disposal would leak the stream's heartbeat for the process lifetime).
// Keyed set (value unused) rather than a bag so a per-identity release can remove
// matching entries without the drain-and-re-add race a ConcurrentBag would force.
// 🚨 MUST hash by REFERENCE: SynchronizationStream is a record whose generated
// GetHashCode recurses into StreamConfiguration (which holds the stream back) —
// value-hashing a stream instance stack-overflows the process. Stream identity
// here IS the instance, so reference semantics is also the correct semantics.
// Hashes by REFERENCE: stream identity here IS the instance. SynchronizationStream now
// declares reference identity itself (the generated structural GetHashCode recursed into
// StreamConfiguration, which holds the stream back — an uncatchable StackOverflow, #2163…),
// so this comparer is no longer load-bearing for that; it is kept because ISynchronizationStream
// is an interface and reference semantics must hold for ANY implementation put in this set.
private readonly ConcurrentDictionary<ISynchronizationStream, byte> _evictedRemoteStreams =
new(StreamReferenceComparer.Instance);

/// <summary>Reference-identity comparer for stream instances — see the
/// <see cref="_evictedRemoteStreams"/> field note (record GetHashCode recursion).</summary>
/// <see cref="_evictedRemoteStreams"/> field note.</summary>
private sealed class StreamReferenceComparer : IEqualityComparer<ISynchronizationStream>
{
public static readonly StreamReferenceComparer Instance = new();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
Name: Portal no longer crashes on a self-referencing data graph
Category: Fix
Description: Fixed an unbounded hash-code recursion that could kill a portal instance outright, dropping every open page on it.
Icon: Sparkle
Order: -20260825
---

# Portal no longer crashes on a self-referencing data graph

Two places in the data layer computed an object's hash code by walking everything it
contained. When the data pointed back at itself — which happens routinely once a
synchronization stream or an entity store is involved — that walk never finished and the
portal process died on the spot, taking every page open on that instance with it.

Streams and their configuration now compare and hash by identity, which is what they always
meant: two live streams are two different streams, never two copies of the same value. Stores
and collections hash from their keys and sizes instead of from the arbitrary objects they
carry, so the cost no longer grows with the size of your workspace either.

A related crash is fixed at the same time: an empty store used to throw when something asked
for its hash code.
Loading
Loading