Skip to content

Fix IdSpan equality violation: Create("") and default now correctly unequal#9902

Merged
ReubenBond merged 3 commits into
mainfrom
copilot/fix-idspan-hashcode-issue
Feb 13, 2026
Merged

Fix IdSpan equality violation: Create("") and default now correctly unequal#9902
ReubenBond merged 3 commits into
mainfrom
copilot/fix-idspan-hashcode-issue

Conversation

Copilot AI commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

IdSpan.Create(string.Empty) and default(IdSpan) were equal despite having different hash codes, violating the fundamental contract that equal objects must have equal hash codes.

Root cause: Equals used Span<byte>.SequenceEqual which treats null.AsSpan() and Array.Empty<byte>().AsSpan() as equal.

Example of the bug:

IdSpan a = default;                    // _value is null, hashCode is 0
IdSpan b = IdSpan.Create("");          // _value is byte[0], hashCode is 90033154

a == b;              // Was: true, Now: false ✓
a.GetHashCode() == b.GetHashCode();    // Was: false (contract violation!)

Changes

  • IdSpan.Equals: Added explicit null checks before SequenceEqual comparison. Two IdSpans are equal only if both have null _value, both have the same reference, or both have non-null arrays with identical byte sequences.

  • IdSpan.CompareTo: Added null handling to maintain consistent ordering: null < empty array < non-empty arrays.

  • Tests: Added IdSpanTests.cs with coverage for equality, hash code consistency, and comparison edge cases.

Note: IdSpan.IsDefault intentionally returns true for both null and empty arrays (logical emptiness), but they remain unequal (different internal representation).

Original prompt

This section details on the original issue you should resolve

<issue_title>IdSpan.Create(string.Empty) and IdSpan = default are equal when the hashcode says they are not</issue_title>
<issue_description>Orleans 9.2.1

   IdSpan defaultGrainId = IdSpan.Create(string.Empty);
   IdSpan emptyGrainId = default;

   Assert.That(defaultGrainId, Is.EqualTo(emptyGrainId));                             // Pass
   Assert.That(defaultGrainId, Is.EqualTo(emptyGrainId));                             // Pass
   Assert.That(defaultGrainId.GetHashCode(), Is.EqualTo(emptyGrainId.GetHashCode())); // Fail

Seems to be a violation of the principle:

A hash function must have the following properties:

If two objects compare as equal, the GetHashCode() method for each object must return the same value. However, if two objects do not compare as equal, the GetHashCode() methods for the two objects do not have to return different values.

https://learn.microsoft.com/en-us/dotnet/api/system.object.gethashcode?view=net-10.0

It looks to me like the problem is actually in the IdSpan.Equals() method
public bool Equals(IdSpan obj) => _value == obj._value || _value.AsSpan().SequenceEqual(obj._value);

In the defaultGrainId case IdSpan._value is byte[0]
In the emptyGrainId case IdSpan._value is null

This I think is the fundamental issue

  Span<byte> nullByteArraySpan = ((byte[]?)null).AsSpan();
  var emptyByteArraySpan = Array.Empty<byte>();
  Assert.That(nullByteArraySpan.SequenceEqual(emptyByteArraySpan), Is.True); // Pass

It's possible IdSpan.CompareTo and other methods that rely on _value.AsSpan() might have similar behaviour.

</issue_description>

<agent_instructions>IdSpan.Create("") and default(IdSpan) should not be equal - fix the equality check</agent_instructions>

Comments on the Issue (you are @copilot in this section)

@ReubenBond We need to make some change here: either `default(IdSpan)` and `IdSpan.Create("")` are equivalent (`a == b` implies `a.GetHashCode() == b.GetHashCode()`) or to make them not equal. I recall we block `string.Empty` at higher layers of the stack but don't block it at lower layers (so that existing serialization methods will continue working).

We should make them not equatable, it seems.</comment_new>


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

Microsoft Reviewers: Open in CodeFlow

Co-authored-by: ReubenBond <203839+ReubenBond@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix IdSpan hashcode mismatch for empty string Fix IdSpan equality violation: Create("") and default now correctly unequal Feb 6, 2026
Copilot AI requested a review from ReubenBond February 6, 2026 23:24
Comment thread src/Orleans.Core.Abstractions/IDs/IdSpan.cs Outdated
public int CompareTo(IdSpan other) => _value.AsSpan().SequenceCompareTo(other._value.AsSpan());
public int CompareTo(IdSpan other)
{
// Handle null cases explicitly

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Such overly verbose comments add no value.

Comment thread src/Orleans.Core.Abstractions/IDs/IdSpan.cs
@ReubenBond ReubenBond marked this pull request as ready for review February 13, 2026 19:32
Copilot AI review requested due to automatic review settings February 13, 2026 19:32
@ReubenBond ReubenBond enabled auto-merge February 13, 2026 19:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a correctness issue in IdSpan by ensuring default(IdSpan) (null backing array) is no longer considered equal to IdSpan.Create("") (empty backing array), which previously violated the Equals/GetHashCode contract.

Changes:

  • Update IdSpan.Equals(IdSpan) to treat null and empty arrays as unequal.
  • Update IdSpan.CompareTo(IdSpan) to provide consistent ordering when either side has a null backing array.
  • Add IdSpanTests covering equality, hash code consistency, and comparison edge cases.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/Orleans.Core.Abstractions/IDs/IdSpan.cs Fixes equality and comparison behavior for null vs empty backing arrays.
test/NonSilo.Tests/IdSpanTests.cs Adds regression tests for the equality/hash-code contract and comparison behavior.

Comment on lines +68 to +72

Assert.True(emptyStringIdSpan.IsDefault);
Assert.Equal("", emptyStringIdSpan.ToString());
// Hash code should be computed from empty byte array, not 0
Assert.NotEqual(0, emptyStringIdSpan.GetHashCode());

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion bakes in an implementation detail that the stable hash of an empty byte array is non-zero. To make the test resilient to future hash algorithm changes while still validating the contract, consider asserting that IdSpan.Create(string.Empty) has a different hash code than default(IdSpan) (and/or that a.Equals(b) implies equal hash codes), rather than asserting != 0 specifically.

Suggested change
Assert.True(emptyStringIdSpan.IsDefault);
Assert.Equal("", emptyStringIdSpan.ToString());
// Hash code should be computed from empty byte array, not 0
Assert.NotEqual(0, emptyStringIdSpan.GetHashCode());
IdSpan defaultIdSpan = default;
Assert.True(emptyStringIdSpan.IsDefault);
Assert.Equal("", emptyStringIdSpan.ToString());
// Hash code should differ from default(IdSpan) while still representing an "empty" value
Assert.NotEqual(defaultIdSpan.GetHashCode(), emptyStringIdSpan.GetHashCode());

Copilot uses AI. Check for mistakes.
Comment on lines +97 to +110
public bool Equals(IdSpan obj)
{
if (_value == obj._value)
{
return true;
}

if (_value is null || obj._value is null)
{
return false;
}

return _value.AsSpan().SequenceEqual(obj._value);
}

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IdSpanCodec currently serializes based on value.AsSpan(). Since AsSpan() returns an empty span for both _value == null and _value.Length == 0, an IdSpan created from "" (empty byte[]) will be serialized the same as default and will deserialize back to default. With the new equality semantics (null != empty), that means serialization no longer round-trips the value. Consider updating IdSpanCodec to encode empty arrays distinctly from null/default (and add a regression test for that).

Copilot uses AI. Check for mistakes.
@ReubenBond ReubenBond added this pull request to the merge queue Feb 13, 2026
Merged via the queue into main with commit 5a939a5 Feb 13, 2026
63 of 65 checks passed
@ReubenBond ReubenBond deleted the copilot/fix-idspan-hashcode-issue branch February 13, 2026 20:26
rkargMsft pushed a commit to rkargMsft/orleans that referenced this pull request Feb 27, 2026
…nequal (dotnet#9902)

* Initial plan

* Fix IdSpan equality and comparison to distinguish null from empty arrays

Co-authored-by: ReubenBond <203839+ReubenBond@users.noreply.github.com>

* Address PR feedback: remove verbose comments and merge null checks

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ReubenBond <203839+ReubenBond@users.noreply.github.com>
Co-authored-by: Reuben Bond <reuben.bond@gmail.com>
@github-actions github-actions Bot locked and limited conversation to collaborators Mar 16, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

IdSpan.Create(string.Empty) and IdSpan = default are equal when the hashcode says they are not

4 participants