Skip to content

Fix Duration Hash.symbol violating the Hash/Equal contract - #7153

Merged
tim-smart merged 3 commits into
Effect-TS:mainfrom
rajanpanth:fix/duration-hash-equal-contract
Aug 9, 2026
Merged

Fix Duration Hash.symbol violating the Hash/Equal contract#7153
tim-smart merged 3 commits into
Effect-TS:mainfrom
rajanpanth:fix/duration-hash-equal-contract

Conversation

@rajanpanth

Copy link
Copy Markdown
Contributor

Summary

Duration.value is a tagged union that can represent the same span of time as either Millis or Nanos (e.g. Duration.seconds(5){ _tag: "Millis", millis: 5000 }, Duration.nanos(5_000_000_000n){ _tag: "Nanos", nanos: 5000000000n }), and Duration.equals/Equal.equals correctly normalize both before comparing. But DurationProto[Hash.symbol] hashes the raw tagged value directly:

const a = Duration.seconds(5)
const b = Duration.nanos(5_000_000_000n)

Duration.equals(a, b)   // true
Equal.equals(a, b)      // false -- disagrees with Duration.equals!
Hash.hash(a) === Hash.hash(b)  // false -- violates the Hash/Equal contract

Equal.ts's own generic comparison documents and relies on the contract (Hash.hash(self) !== Hash.hash(that) short-circuits to false before ever calling [Equal.symbol]), so this silently breaks HashSet/HashMap lookups keyed by Duration whenever the same duration was constructed via a different code path than the one used to build/insert into the set/map — a very ordinary occurrence, since library internals mix Duration.millis, Duration.seconds, and Duration.nanos freely.

Changes

  • packages/effect/src/Duration.ts: [Hash.symbol] now hashes the canonical nanoseconds form (toNanosUnsafe) for finite durations, matching the basis Equivalence/matchPair already normalize to for cross-representation comparisons. Infinity/NegativeInfinity keep their existing structural hash, since they have no numeric representation to normalize and each already has exactly one tagged form.
  • packages/effect/test/Duration.test.ts: one new test confirming Hash.symbol agrees with equals/Equal.equals across representations, and that a HashSet lookup succeeds across them.
  • Added a changeset.

Testing

  • Full Duration.test.ts: 44/44 passing (up from 43).
  • Confirmed the new test fails on the prior code (fails at the Equal.equals fast-path check, before even reaching my own Hash.hash assertion) and passes with the fix.
  • Duration.test.ts/HashSet.test.ts: 70/70 passing, no regressions.
  • tsc -b, oxlint, dprint check clean.

Duration.value is a tagged union that can represent the same span of
time as either Millis or Nanos (e.g. seconds(5) vs
nanos(5_000_000_000n)), and Duration.equals/Equal.equals correctly
normalize both representations before comparing. But
DurationProto[Hash.symbol] hashed the raw tagged value directly, so
two durations that compare as equal could hash differently -- which
Equal.ts's own generic comparison exploits as a fast-path
(Hash.hash(self) !== Hash.hash(that) short-circuits to false before
ever calling [Equal.symbol]), and which silently breaks HashSet/
HashMap lookups keyed by Duration.

Hash the canonical nanoseconds form for finite durations instead.
Infinity/NegativeInfinity have no numeric representation to
normalize, but each already has exactly one tagged form, so their
existing structural hash is unaffected.
@changeset-bot

changeset-bot Bot commented Aug 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 511a59a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 30 packages
Name Type
effect Patch
@effect/ai-anthropic Patch
@effect/ai-openai Patch
@effect/ai-openai-compat Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch
@effect/opentelemetry Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node Patch
@effect/platform-node-shared Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/vitest Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
basic.ts 6.92 KB 6.92 KB 0.00 KB (0.00%)
batching.ts 9.72 KB 9.72 KB 0.00 KB (0.00%)
brand.ts 6.60 KB 6.60 KB 0.00 KB (0.00%)
cache.ts 10.63 KB 10.59 KB +0.04 KB (+0.38%)
config.ts 20.91 KB 20.91 KB 0.00 KB (0.00%)
differ.ts 19.77 KB 19.77 KB 0.00 KB (0.00%)
http-client.ts 21.55 KB 21.52 KB +0.04 KB (+0.18%)
logger.ts 10.84 KB 10.81 KB +0.03 KB (+0.31%)
metric.ts 8.86 KB 8.86 KB 0.00 KB (0.00%)
optic.ts 6.68 KB 6.68 KB 0.00 KB (0.00%)
pubsub.ts 14.90 KB 14.86 KB +0.04 KB (+0.24%)
queue.ts 11.57 KB 11.54 KB +0.04 KB (+0.31%)
schedule.ts 10.74 KB 10.71 KB +0.04 KB (+0.34%)
schema-class.ts 19.48 KB 19.48 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 29.41 KB 29.41 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 25.63 KB 25.63 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.58 KB 13.55 KB +0.04 KB (+0.28%)
schema-string.ts 11.09 KB 11.09 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.38 KB 15.38 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 21.52 KB 21.52 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.00 KB 24.00 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 18.74 KB 18.74 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 18.57 KB 18.57 KB 0.00 KB (0.00%)
schema-toFormatter.ts 18.43 KB 18.43 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 22.59 KB 22.59 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.08 KB 19.08 KB 0.00 KB (0.00%)
schema.ts 18.73 KB 18.73 KB 0.00 KB (0.00%)
stm.ts 12.63 KB 12.59 KB +0.04 KB (+0.34%)
stream.ts 9.67 KB 9.67 KB 0.00 KB (0.00%)

@pullfrog pullfrog Bot 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.

✅ No new issues found.

Reviewed changes

  • Duration hash fixDurationProto[Hash.symbol] now hashes the canonical nanoseconds form (Hash.hash(toNanosUnsafe(this))) for finite durations instead of the raw Millis/Nanos tag structure, while Infinity/NegativeInfinity keep their structural hash. This aligns the hash with the normalized basis Equivalence already compares on.
  • Regression test — new test asserting Duration.equals, Equal.equals, equal Hash.hash, and a HashSet lookup across the Millis/Nanos representations.
  • Changesetpatch bump that accurately describes the behavior fix.

I verified the fix end-to-end: with it applied, all 44 Duration.test.ts tests pass and oxlint is clean; I confirmed the diagnosis in the PR description is correct (Equal.ts:232 short-circuits to false on a hash mismatch, so Equal.equals/HashSet/HashMap disagreed with Duration.equals), and temporarily reverting the change makes the new test fail exactly at the Equal.equals assertion, so the coverage is genuine. The hash change is consistent with equals in every case: millis-vs-millis, nanos-vs-nanos, and cross-representation comparisons all funnel through the same roundMillisToNanos canonicalization, so equal durations always produce equal hashes (collisions where distinct durations normalize to the same nanos are inherent to the chosen equality basis and correct). Nice, minimal fix.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot 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.

✅ No new issues found.

Reviewed changes

  • Overflow guard for the Millis hashing path — the [Hash.symbol] switch now returns Hash.number(millis) when millis * 1_000_000 overflows Number.MAX_VALUE, instead of funneling through roundMillisToNanos/toNanosUnsafe. I confirmed the prior code crashed with RangeError (BigInt(Infinity)) on Hash.hash(Duration.millis(1e303)); the new guard boundary matches exactly where that bigint conversion throws, and equality for such a duration against any Nanos sibling throws rather than returning true, so no equal pair can span the fallback — the Hash/Equal invariant is preserved in every reachable case.
  • Extended contract test — added negative cross-representation (millis(-5000) vs nanos(-5_000_000_000n)), a determinism assertion for infinity's structural hash, and a negativeInfinity ≠ infinity check.
  • Overflow regression testDuration.millis(1e303) self-equality plus HashSet round-trip; this genuinely fails (throws) on the prior commit's code.

Verified locally: 45/45 Duration.test.ts and 26/26 HashSet.test.ts pass; oxlint and dprint check clean on the changed files. Nice, tightly-scoped follow-up.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot 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.

✅ No new issues found.

Reviewed changes

  • Avoided duplicate nanos conversion in the Millis hashing path — commit 511a59a refactors the [Hash.symbol] Millis case to compute nanos = this.value.millis * 1_000_000 once and call Hash.hash(roundTiesAwayFromZero(nanos)) directly (reusing the same nanos in the Number.isFinite guard) instead of going through the roundMillisToNanos wrapper.

I confirmed this preserves behavior exactly: roundMillisToNanos(millis) is defined as roundTiesAwayFromZero(millis * 1_000_000) (Duration.ts:41), so the emitted hash, the overflow guard, and the Hash.number(millis) fallback are byte-identical to the previous commit's output. 45/45 Duration.test.ts tests pass, and oxlint + dprint are clean on the changed file. The refactor also marginally tightens correctness since the guard and the rounding now provably see the same nanos value.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@tim-smart
tim-smart enabled auto-merge (squash) August 9, 2026 21:33
@tim-smart
tim-smart merged commit 9611ed4 into Effect-TS:main Aug 9, 2026
27 of 30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants