Skip to content

fix: prevent hashCode recomputation when computed value is 0 in SegmentCodec#3985

Merged
987Nabil merged 1 commit intomainfrom
hot-path/segmentcodec-hashcode-sentinel
Mar 24, 2026
Merged

fix: prevent hashCode recomputation when computed value is 0 in SegmentCodec#3985
987Nabil merged 1 commit intomainfrom
hot-path/segmentcodec-hashcode-sentinel

Conversation

@guizmaii
Copy link
Copy Markdown
Member

@guizmaii guizmaii commented Mar 2, 2026

Summary

  • Applies the if (h == 0) 1 else h cached hashCode sentinel pattern to SegmentCodec.hashCode
  • Prevents recomputation (including Tuple2 allocation) on every hashCode call when the computed value happens to be 0

Background: the cached hashCode sentinel pattern

SegmentCodec.hashCode uses the lazily-initialized cached hashCode pattern described in Joshua Bloch's Effective Java (Item 11, 3rd edition):

// Effective Java, Item 11 — Lazily initialized, cached hashCode
private volatile int hashCode;

@Override public int hashCode() {
    int result = hashCode;
    if (result == 0) {
        result = /* compute hash */;
        hashCode = result;
    }
    return result;
}

The canonical real-world example is java.lang.String.hashCode() in OpenJDK, which uses hash == 0 as the sentinel for "not yet computed."

The one weakness of this pattern is that when the computed hash genuinely equals 0 (~1 in 2^32 probability), it will be recomputed on every call. The if (h == 0) 1 else h refinement remaps hash-0 to hash-1, eliminating even that edge case. The collision of mapping one extra value to 1 is negligible.

Test plan

  • Existing SegmentCodecSpec tests pass (7 tests)
  • New test verifies hashCode is stable across multiple calls and non-zero
  • New test verifies all standard segment codec types produce non-zero hashCode

Use the standard if-zero-then-one sentinel pattern to avoid
recomputing the hashCode on every call when the computed value
happens to be 0.

Co-Authored-By: Jules Ivanic <jules.ivanic@gmail.com>
@guizmaii guizmaii self-assigned this Mar 2, 2026
@netlify
Copy link
Copy Markdown

netlify Bot commented Mar 2, 2026

Deploy Preview for zio-http ready!

Name Link
🔨 Latest commit 5b8390b
🔍 Latest deploy log https://app.netlify.com/projects/zio-http/deploys/69a5353b74aae300088bc3d6
😎 Deploy Preview https://deploy-preview-3985--zio-http.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@987Nabil 987Nabil marked this pull request as ready for review March 24, 2026 18:44
Copilot AI review requested due to automatic review settings March 24, 2026 18:44
@987Nabil 987Nabil merged commit 86b24ff into main Mar 24, 2026
84 checks passed
@987Nabil 987Nabil deleted the hot-path/segmentcodec-hashcode-sentinel branch March 24, 2026 18:44
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Updates SegmentCodec.hashCode caching to avoid the “computed hash is 0” sentinel collision, and adds tests around hashCode stability / non-zero behavior.

Changes:

  • Remap computed hash 0 to 1 when populating the cached hash code.
  • Add spec coverage asserting hashCode stability and that standard codecs produce non-zero hash codes.

Reviewed changes

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

File Description
zio-http/shared/src/main/scala/zio/http/codec/SegmentCodec.scala Adjusts cached hash computation to remap 0 to 1 before caching.
zio-http/shared/src/test/scala/zio/http/codec/SegmentCodecSpec.scala Adds tests asserting stable and non-zero hashCode behavior for segment codecs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +105 to +122
test("hashCode is stable across multiple calls") {
val codec = SegmentCodec.literal("test")
val hash1 = codec.hashCode
val hash2 = codec.hashCode
assertTrue(hash1 == hash2, hash1 != 0)
},
test("hashCode is non-zero for all standard segment codecs") {
val codecs: List[SegmentCodec[_]] = List(
SegmentCodec.empty,
SegmentCodec.literal("users"),
SegmentCodec.int("id"),
SegmentCodec.long("id"),
SegmentCodec.string("name"),
SegmentCodec.uuid("uuid"),
SegmentCodec.trailing,
)
assertTrue(codecs.forall(_.hashCode != 0))
},
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

These tests don’t deterministically cover the specific regression this PR fixes (the computed hash being exactly 0 is astronomically unlikely, so the tests will almost always pass even without the remapping). To actually validate the behavior, consider making the raw hash computation injectable/overridable (e.g., a protected[codec] def computeHash: Int) so a test-only codec can force computeHash == 0 and assert the cached value becomes 1 and remains stable.

Copilot uses AI. Check for mistakes.
if (_hashCode == 0) _hashCode = (this.getClass.getName(), render).hashCode
if (_hashCode == 0) {
val h = (this.getClass.getName(), render).hashCode
_hashCode = if (h == 0) 1 else h
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

The remap-to-1 behavior is a subtle sentinel workaround, but it’s not self-explanatory in code. Please add a short inline comment (or a named constant) explaining that 0 is reserved as the 'not computed' sentinel and is therefore remapped to avoid recomputation.

Suggested change
_hashCode = if (h == 0) 1 else h
_hashCode = if (h == 0) 1 else h // 0 is reserved as "not computed", so remap real hash 0 to 1 to avoid recomputation

Copilot uses AI. Check for mistakes.
@@ -45,7 +45,10 @@ sealed trait SegmentCodec[A] { self =>
def format(value: A): Path

override val hashCode: Int = {
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

The PR description discusses a lazily-initialized cached hashCode that can be recomputed on every call when the computed value is 0. In the diff, hashCode is overridden as a val, which is computed once at initialization time and won’t be recomputed on subsequent calls regardless of the sentinel value. Either adjust the implementation to match the described lazy-cached pattern (i.e., def hashCode with a cached var) or update the PR description/tests to match the eager val behavior.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants