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:

consta=Duration.seconds(5)constb=Duration.nanos(5_000_000_000n)Duration.equals(a,b)// trueEqual.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-botBot 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
NameType
effectPatch
@effect/ai-anthropicPatch
@effect/ai-openaiPatch
@effect/ai-openai-compatPatch
@effect/ai-openrouterPatch
@effect/atom-reactPatch
@effect/atom-solidPatch
@effect/atom-vuePatch
@effect/docgenPatch
@effect/doctestPatch
@effect/openapi-generatorPatch
@effect/opentelemetryPatch
@effect/platform-browserPatch
@effect/platform-bunPatch
@effect/platform-denoPatch
@effect/platform-nodePatch
@effect/platform-node-sharedPatch
@effect/sql-clickhousePatch
@effect/sql-d1Patch
@effect/sql-libsqlPatch
@effect/sql-mssqlPatch
@effect/sql-mysql2Patch
@effect/sql-pgPatch
@effect/sql-pglitePatch
@effect/sql-sqlite-bunPatch
@effect/sql-sqlite-doPatch
@effect/sql-sqlite-nodePatch
@effect/sql-sqlite-react-nativePatch
@effect/sql-sqlite-wasmPatch
@effect/vitestPatch

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-actionsBot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

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

File NameCurrent SizePrevious SizeDifference
basic.ts6.92 KB6.92 KB0.00 KB (0.00%)
batching.ts9.72 KB9.72 KB0.00 KB (0.00%)
brand.ts6.60 KB6.60 KB0.00 KB (0.00%)
cache.ts10.63 KB10.59 KB+0.04 KB (+0.38%)
config.ts20.91 KB20.91 KB0.00 KB (0.00%)
differ.ts19.77 KB19.77 KB0.00 KB (0.00%)
http-client.ts21.55 KB21.52 KB+0.04 KB (+0.18%)
logger.ts10.84 KB10.81 KB+0.03 KB (+0.31%)
metric.ts8.86 KB8.86 KB0.00 KB (0.00%)
optic.ts6.68 KB6.68 KB0.00 KB (0.00%)
pubsub.ts14.90 KB14.86 KB+0.04 KB (+0.24%)
queue.ts11.57 KB11.54 KB+0.04 KB (+0.31%)
schedule.ts10.74 KB10.71 KB+0.04 KB (+0.34%)
schema-class.ts19.48 KB19.48 KB0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts29.41 KB29.41 KB0.00 KB (0.00%)
schema-representation-roundtrip.ts25.63 KB25.63 KB0.00 KB (0.00%)
schema-string-transformation.ts13.58 KB13.55 KB+0.04 KB (+0.28%)
schema-string.ts11.09 KB11.09 KB0.00 KB (0.00%)
schema-template-literal.ts15.38 KB15.38 KB0.00 KB (0.00%)
schema-toArbitrary.ts21.52 KB21.52 KB0.00 KB (0.00%)
schema-toCodeDocument.ts24.00 KB24.00 KB0.00 KB (0.00%)
schema-toCodecJson.ts18.74 KB18.74 KB0.00 KB (0.00%)
schema-toEquivalence.ts18.57 KB18.57 KB0.00 KB (0.00%)
schema-toFormatter.ts18.43 KB18.43 KB0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts22.59 KB22.59 KB0.00 KB (0.00%)
schema-toRepresentation.ts19.08 KB19.08 KB0.00 KB (0.00%)
schema.ts18.73 KB18.73 KB0.00 KB (0.00%)
stm.ts12.63 KB12.59 KB+0.04 KB (+0.34%)
stream.ts9.67 KB9.67 KB0.00 KB (0.00%)

pullfrog[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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/45Duration.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:mainAug 9, 2026
27 of 30 checks passed
@effect-boteffect-bot mentioned this pull request Aug 9, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rajanpanth@tim-smart
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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:

consta=Duration.seconds(5)constb=Duration.nanos(5_000_000_000n)Duration.equals(a,b)// trueEqual.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-botBot 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
NameType
effectPatch
@effect/ai-anthropicPatch
@effect/ai-openaiPatch
@effect/ai-openai-compatPatch
@effect/ai-openrouterPatch
@effect/atom-reactPatch
@effect/atom-solidPatch
@effect/atom-vuePatch
@effect/docgenPatch
@effect/doctestPatch
@effect/openapi-generatorPatch
@effect/opentelemetryPatch
@effect/platform-browserPatch
@effect/platform-bunPatch
@effect/platform-denoPatch
@effect/platform-nodePatch
@effect/platform-node-sharedPatch
@effect/sql-clickhousePatch
@effect/sql-d1Patch
@effect/sql-libsqlPatch
@effect/sql-mssqlPatch
@effect/sql-mysql2Patch
@effect/sql-pgPatch
@effect/sql-pglitePatch
@effect/sql-sqlite-bunPatch
@effect/sql-sqlite-doPatch
@effect/sql-sqlite-nodePatch
@effect/sql-sqlite-react-nativePatch
@effect/sql-sqlite-wasmPatch
@effect/vitestPatch

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-actionsBot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

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

File NameCurrent SizePrevious SizeDifference
basic.ts6.92 KB6.92 KB0.00 KB (0.00%)
batching.ts9.72 KB9.72 KB0.00 KB (0.00%)
brand.ts6.60 KB6.60 KB0.00 KB (0.00%)
cache.ts10.63 KB10.59 KB+0.04 KB (+0.38%)
config.ts20.91 KB20.91 KB0.00 KB (0.00%)
differ.ts19.77 KB19.77 KB0.00 KB (0.00%)
http-client.ts21.55 KB21.52 KB+0.04 KB (+0.18%)
logger.ts10.84 KB10.81 KB+0.03 KB (+0.31%)
metric.ts8.86 KB8.86 KB0.00 KB (0.00%)
optic.ts6.68 KB6.68 KB0.00 KB (0.00%)
pubsub.ts14.90 KB14.86 KB+0.04 KB (+0.24%)
queue.ts11.57 KB11.54 KB+0.04 KB (+0.31%)
schedule.ts10.74 KB10.71 KB+0.04 KB (+0.34%)
schema-class.ts19.48 KB19.48 KB0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts29.41 KB29.41 KB0.00 KB (0.00%)
schema-representation-roundtrip.ts25.63 KB25.63 KB0.00 KB (0.00%)
schema-string-transformation.ts13.58 KB13.55 KB+0.04 KB (+0.28%)
schema-string.ts11.09 KB11.09 KB0.00 KB (0.00%)
schema-template-literal.ts15.38 KB15.38 KB0.00 KB (0.00%)
schema-toArbitrary.ts21.52 KB21.52 KB0.00 KB (0.00%)
schema-toCodeDocument.ts24.00 KB24.00 KB0.00 KB (0.00%)
schema-toCodecJson.ts18.74 KB18.74 KB0.00 KB (0.00%)
schema-toEquivalence.ts18.57 KB18.57 KB0.00 KB (0.00%)
schema-toFormatter.ts18.43 KB18.43 KB0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts22.59 KB22.59 KB0.00 KB (0.00%)
schema-toRepresentation.ts19.08 KB19.08 KB0.00 KB (0.00%)
schema.ts18.73 KB18.73 KB0.00 KB (0.00%)
stm.ts12.63 KB12.59 KB+0.04 KB (+0.34%)
stream.ts9.67 KB9.67 KB0.00 KB (0.00%)

pullfrog[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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/45Duration.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:mainAug 9, 2026
27 of 30 checks passed
@effect-boteffect-bot mentioned this pull request Aug 9, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rajanpanth@tim-smart
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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:

consta=Duration.seconds(5)constb=Duration.nanos(5_000_000_000n)Duration.equals(a,b)// trueEqual.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-botBot 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
NameType
effectPatch
@effect/ai-anthropicPatch
@effect/ai-openaiPatch
@effect/ai-openai-compatPatch
@effect/ai-openrouterPatch
@effect/atom-reactPatch
@effect/atom-solidPatch
@effect/atom-vuePatch
@effect/docgenPatch
@effect/doctestPatch
@effect/openapi-generatorPatch
@effect/opentelemetryPatch
@effect/platform-browserPatch
@effect/platform-bunPatch
@effect/platform-denoPatch
@effect/platform-nodePatch
@effect/platform-node-sharedPatch
@effect/sql-clickhousePatch
@effect/sql-d1Patch
@effect/sql-libsqlPatch
@effect/sql-mssqlPatch
@effect/sql-mysql2Patch
@effect/sql-pgPatch
@effect/sql-pglitePatch
@effect/sql-sqlite-bunPatch
@effect/sql-sqlite-doPatch
@effect/sql-sqlite-nodePatch
@effect/sql-sqlite-react-nativePatch
@effect/sql-sqlite-wasmPatch
@effect/vitestPatch

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-actionsBot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

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

File NameCurrent SizePrevious SizeDifference
basic.ts6.92 KB6.92 KB0.00 KB (0.00%)
batching.ts9.72 KB9.72 KB0.00 KB (0.00%)
brand.ts6.60 KB6.60 KB0.00 KB (0.00%)
cache.ts10.63 KB10.59 KB+0.04 KB (+0.38%)
config.ts20.91 KB20.91 KB0.00 KB (0.00%)
differ.ts19.77 KB19.77 KB0.00 KB (0.00%)
http-client.ts21.55 KB21.52 KB+0.04 KB (+0.18%)
logger.ts10.84 KB10.81 KB+0.03 KB (+0.31%)
metric.ts8.86 KB8.86 KB0.00 KB (0.00%)
optic.ts6.68 KB6.68 KB0.00 KB (0.00%)
pubsub.ts14.90 KB14.86 KB+0.04 KB (+0.24%)
queue.ts11.57 KB11.54 KB+0.04 KB (+0.31%)
schedule.ts10.74 KB10.71 KB+0.04 KB (+0.34%)
schema-class.ts19.48 KB19.48 KB0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts29.41 KB29.41 KB0.00 KB (0.00%)
schema-representation-roundtrip.ts25.63 KB25.63 KB0.00 KB (0.00%)
schema-string-transformation.ts13.58 KB13.55 KB+0.04 KB (+0.28%)
schema-string.ts11.09 KB11.09 KB0.00 KB (0.00%)
schema-template-literal.ts15.38 KB15.38 KB0.00 KB (0.00%)
schema-toArbitrary.ts21.52 KB21.52 KB0.00 KB (0.00%)
schema-toCodeDocument.ts24.00 KB24.00 KB0.00 KB (0.00%)
schema-toCodecJson.ts18.74 KB18.74 KB0.00 KB (0.00%)
schema-toEquivalence.ts18.57 KB18.57 KB0.00 KB (0.00%)
schema-toFormatter.ts18.43 KB18.43 KB0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts22.59 KB22.59 KB0.00 KB (0.00%)
schema-toRepresentation.ts19.08 KB19.08 KB0.00 KB (0.00%)
schema.ts18.73 KB18.73 KB0.00 KB (0.00%)
stm.ts12.63 KB12.59 KB+0.04 KB (+0.34%)
stream.ts9.67 KB9.67 KB0.00 KB (0.00%)

pullfrog[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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/45Duration.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:mainAug 9, 2026
27 of 30 checks passed
@effect-boteffect-bot mentioned this pull request Aug 9, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rajanpanth@tim-smart
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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:

consta=Duration.seconds(5)constb=Duration.nanos(5_000_000_000n)Duration.equals(a,b)// trueEqual.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-botBot 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
NameType
effectPatch
@effect/ai-anthropicPatch
@effect/ai-openaiPatch
@effect/ai-openai-compatPatch
@effect/ai-openrouterPatch
@effect/atom-reactPatch
@effect/atom-solidPatch
@effect/atom-vuePatch
@effect/docgenPatch
@effect/doctestPatch
@effect/openapi-generatorPatch
@effect/opentelemetryPatch
@effect/platform-browserPatch
@effect/platform-bunPatch
@effect/platform-denoPatch
@effect/platform-nodePatch
@effect/platform-node-sharedPatch
@effect/sql-clickhousePatch
@effect/sql-d1Patch
@effect/sql-libsqlPatch
@effect/sql-mssqlPatch
@effect/sql-mysql2Patch
@effect/sql-pgPatch
@effect/sql-pglitePatch
@effect/sql-sqlite-bunPatch
@effect/sql-sqlite-doPatch
@effect/sql-sqlite-nodePatch
@effect/sql-sqlite-react-nativePatch
@effect/sql-sqlite-wasmPatch
@effect/vitestPatch

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-actionsBot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

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

File NameCurrent SizePrevious SizeDifference
basic.ts6.92 KB6.92 KB0.00 KB (0.00%)
batching.ts9.72 KB9.72 KB0.00 KB (0.00%)
brand.ts6.60 KB6.60 KB0.00 KB (0.00%)
cache.ts10.63 KB10.59 KB+0.04 KB (+0.38%)
config.ts20.91 KB20.91 KB0.00 KB (0.00%)
differ.ts19.77 KB19.77 KB0.00 KB (0.00%)
http-client.ts21.55 KB21.52 KB+0.04 KB (+0.18%)
logger.ts10.84 KB10.81 KB+0.03 KB (+0.31%)
metric.ts8.86 KB8.86 KB0.00 KB (0.00%)
optic.ts6.68 KB6.68 KB0.00 KB (0.00%)
pubsub.ts14.90 KB14.86 KB+0.04 KB (+0.24%)
queue.ts11.57 KB11.54 KB+0.04 KB (+0.31%)
schedule.ts10.74 KB10.71 KB+0.04 KB (+0.34%)
schema-class.ts19.48 KB19.48 KB0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts29.41 KB29.41 KB0.00 KB (0.00%)
schema-representation-roundtrip.ts25.63 KB25.63 KB0.00 KB (0.00%)
schema-string-transformation.ts13.58 KB13.55 KB+0.04 KB (+0.28%)
schema-string.ts11.09 KB11.09 KB0.00 KB (0.00%)
schema-template-literal.ts15.38 KB15.38 KB0.00 KB (0.00%)
schema-toArbitrary.ts21.52 KB21.52 KB0.00 KB (0.00%)
schema-toCodeDocument.ts24.00 KB24.00 KB0.00 KB (0.00%)
schema-toCodecJson.ts18.74 KB18.74 KB0.00 KB (0.00%)
schema-toEquivalence.ts18.57 KB18.57 KB0.00 KB (0.00%)
schema-toFormatter.ts18.43 KB18.43 KB0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts22.59 KB22.59 KB0.00 KB (0.00%)
schema-toRepresentation.ts19.08 KB19.08 KB0.00 KB (0.00%)
schema.ts18.73 KB18.73 KB0.00 KB (0.00%)
stm.ts12.63 KB12.59 KB+0.04 KB (+0.34%)
stream.ts9.67 KB9.67 KB0.00 KB (0.00%)

pullfrog[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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/45Duration.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:mainAug 9, 2026
27 of 30 checks passed
@effect-boteffect-bot mentioned this pull request Aug 9, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rajanpanth@tim-smart
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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:

consta=Duration.seconds(5)constb=Duration.nanos(5_000_000_000n)Duration.equals(a,b)// trueEqual.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-botBot 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
NameType
effectPatch
@effect/ai-anthropicPatch
@effect/ai-openaiPatch
@effect/ai-openai-compatPatch
@effect/ai-openrouterPatch
@effect/atom-reactPatch
@effect/atom-solidPatch
@effect/atom-vuePatch
@effect/docgenPatch
@effect/doctestPatch
@effect/openapi-generatorPatch
@effect/opentelemetryPatch
@effect/platform-browserPatch
@effect/platform-bunPatch
@effect/platform-denoPatch
@effect/platform-nodePatch
@effect/platform-node-sharedPatch
@effect/sql-clickhousePatch
@effect/sql-d1Patch
@effect/sql-libsqlPatch
@effect/sql-mssqlPatch
@effect/sql-mysql2Patch
@effect/sql-pgPatch
@effect/sql-pglitePatch
@effect/sql-sqlite-bunPatch
@effect/sql-sqlite-doPatch
@effect/sql-sqlite-nodePatch
@effect/sql-sqlite-react-nativePatch
@effect/sql-sqlite-wasmPatch
@effect/vitestPatch

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-actionsBot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

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

File NameCurrent SizePrevious SizeDifference
basic.ts6.92 KB6.92 KB0.00 KB (0.00%)
batching.ts9.72 KB9.72 KB0.00 KB (0.00%)
brand.ts6.60 KB6.60 KB0.00 KB (0.00%)
cache.ts10.63 KB10.59 KB+0.04 KB (+0.38%)
config.ts20.91 KB20.91 KB0.00 KB (0.00%)
differ.ts19.77 KB19.77 KB0.00 KB (0.00%)
http-client.ts21.55 KB21.52 KB+0.04 KB (+0.18%)
logger.ts10.84 KB10.81 KB+0.03 KB (+0.31%)
metric.ts8.86 KB8.86 KB0.00 KB (0.00%)
optic.ts6.68 KB6.68 KB0.00 KB (0.00%)
pubsub.ts14.90 KB14.86 KB+0.04 KB (+0.24%)
queue.ts11.57 KB11.54 KB+0.04 KB (+0.31%)
schedule.ts10.74 KB10.71 KB+0.04 KB (+0.34%)
schema-class.ts19.48 KB19.48 KB0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts29.41 KB29.41 KB0.00 KB (0.00%)
schema-representation-roundtrip.ts25.63 KB25.63 KB0.00 KB (0.00%)
schema-string-transformation.ts13.58 KB13.55 KB+0.04 KB (+0.28%)
schema-string.ts11.09 KB11.09 KB0.00 KB (0.00%)
schema-template-literal.ts15.38 KB15.38 KB0.00 KB (0.00%)
schema-toArbitrary.ts21.52 KB21.52 KB0.00 KB (0.00%)
schema-toCodeDocument.ts24.00 KB24.00 KB0.00 KB (0.00%)
schema-toCodecJson.ts18.74 KB18.74 KB0.00 KB (0.00%)
schema-toEquivalence.ts18.57 KB18.57 KB0.00 KB (0.00%)
schema-toFormatter.ts18.43 KB18.43 KB0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts22.59 KB22.59 KB0.00 KB (0.00%)
schema-toRepresentation.ts19.08 KB19.08 KB0.00 KB (0.00%)
schema.ts18.73 KB18.73 KB0.00 KB (0.00%)
stm.ts12.63 KB12.59 KB+0.04 KB (+0.34%)
stream.ts9.67 KB9.67 KB0.00 KB (0.00%)

pullfrog[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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/45Duration.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:mainAug 9, 2026
27 of 30 checks passed
@effect-boteffect-bot mentioned this pull request Aug 9, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rajanpanth@tim-smart
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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:

consta=Duration.seconds(5)constb=Duration.nanos(5_000_000_000n)Duration.equals(a,b)// trueEqual.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-botBot 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
NameType
effectPatch
@effect/ai-anthropicPatch
@effect/ai-openaiPatch
@effect/ai-openai-compatPatch
@effect/ai-openrouterPatch
@effect/atom-reactPatch
@effect/atom-solidPatch
@effect/atom-vuePatch
@effect/docgenPatch
@effect/doctestPatch
@effect/openapi-generatorPatch
@effect/opentelemetryPatch
@effect/platform-browserPatch
@effect/platform-bunPatch
@effect/platform-denoPatch
@effect/platform-nodePatch
@effect/platform-node-sharedPatch
@effect/sql-clickhousePatch
@effect/sql-d1Patch
@effect/sql-libsqlPatch
@effect/sql-mssqlPatch
@effect/sql-mysql2Patch
@effect/sql-pgPatch
@effect/sql-pglitePatch
@effect/sql-sqlite-bunPatch
@effect/sql-sqlite-doPatch
@effect/sql-sqlite-nodePatch
@effect/sql-sqlite-react-nativePatch
@effect/sql-sqlite-wasmPatch
@effect/vitestPatch

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-actionsBot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

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

File NameCurrent SizePrevious SizeDifference
basic.ts6.92 KB6.92 KB0.00 KB (0.00%)
batching.ts9.72 KB9.72 KB0.00 KB (0.00%)
brand.ts6.60 KB6.60 KB0.00 KB (0.00%)
cache.ts10.63 KB10.59 KB+0.04 KB (+0.38%)
config.ts20.91 KB20.91 KB0.00 KB (0.00%)
differ.ts19.77 KB19.77 KB0.00 KB (0.00%)
http-client.ts21.55 KB21.52 KB+0.04 KB (+0.18%)
logger.ts10.84 KB10.81 KB+0.03 KB (+0.31%)
metric.ts8.86 KB8.86 KB0.00 KB (0.00%)
optic.ts6.68 KB6.68 KB0.00 KB (0.00%)
pubsub.ts14.90 KB14.86 KB+0.04 KB (+0.24%)
queue.ts11.57 KB11.54 KB+0.04 KB (+0.31%)
schedule.ts10.74 KB10.71 KB+0.04 KB (+0.34%)
schema-class.ts19.48 KB19.48 KB0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts29.41 KB29.41 KB0.00 KB (0.00%)
schema-representation-roundtrip.ts25.63 KB25.63 KB0.00 KB (0.00%)
schema-string-transformation.ts13.58 KB13.55 KB+0.04 KB (+0.28%)
schema-string.ts11.09 KB11.09 KB0.00 KB (0.00%)
schema-template-literal.ts15.38 KB15.38 KB0.00 KB (0.00%)
schema-toArbitrary.ts21.52 KB21.52 KB0.00 KB (0.00%)
schema-toCodeDocument.ts24.00 KB24.00 KB0.00 KB (0.00%)
schema-toCodecJson.ts18.74 KB18.74 KB0.00 KB (0.00%)
schema-toEquivalence.ts18.57 KB18.57 KB0.00 KB (0.00%)
schema-toFormatter.ts18.43 KB18.43 KB0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts22.59 KB22.59 KB0.00 KB (0.00%)
schema-toRepresentation.ts19.08 KB19.08 KB0.00 KB (0.00%)
schema.ts18.73 KB18.73 KB0.00 KB (0.00%)
stm.ts12.63 KB12.59 KB+0.04 KB (+0.34%)
stream.ts9.67 KB9.67 KB0.00 KB (0.00%)

pullfrog[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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/45Duration.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:mainAug 9, 2026
27 of 30 checks passed
@effect-boteffect-bot mentioned this pull request Aug 9, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rajanpanth@tim-smart
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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:

consta=Duration.seconds(5)constb=Duration.nanos(5_000_000_000n)Duration.equals(a,b)// trueEqual.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-botBot 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
NameType
effectPatch
@effect/ai-anthropicPatch
@effect/ai-openaiPatch
@effect/ai-openai-compatPatch
@effect/ai-openrouterPatch
@effect/atom-reactPatch
@effect/atom-solidPatch
@effect/atom-vuePatch
@effect/docgenPatch
@effect/doctestPatch
@effect/openapi-generatorPatch
@effect/opentelemetryPatch
@effect/platform-browserPatch
@effect/platform-bunPatch
@effect/platform-denoPatch
@effect/platform-nodePatch
@effect/platform-node-sharedPatch
@effect/sql-clickhousePatch
@effect/sql-d1Patch
@effect/sql-libsqlPatch
@effect/sql-mssqlPatch
@effect/sql-mysql2Patch
@effect/sql-pgPatch
@effect/sql-pglitePatch
@effect/sql-sqlite-bunPatch
@effect/sql-sqlite-doPatch
@effect/sql-sqlite-nodePatch
@effect/sql-sqlite-react-nativePatch
@effect/sql-sqlite-wasmPatch
@effect/vitestPatch

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-actionsBot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

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

File NameCurrent SizePrevious SizeDifference
basic.ts6.92 KB6.92 KB0.00 KB (0.00%)
batching.ts9.72 KB9.72 KB0.00 KB (0.00%)
brand.ts6.60 KB6.60 KB0.00 KB (0.00%)
cache.ts10.63 KB10.59 KB+0.04 KB (+0.38%)
config.ts20.91 KB20.91 KB0.00 KB (0.00%)
differ.ts19.77 KB19.77 KB0.00 KB (0.00%)
http-client.ts21.55 KB21.52 KB+0.04 KB (+0.18%)
logger.ts10.84 KB10.81 KB+0.03 KB (+0.31%)
metric.ts8.86 KB8.86 KB0.00 KB (0.00%)
optic.ts6.68 KB6.68 KB0.00 KB (0.00%)
pubsub.ts14.90 KB14.86 KB+0.04 KB (+0.24%)
queue.ts11.57 KB11.54 KB+0.04 KB (+0.31%)
schedule.ts10.74 KB10.71 KB+0.04 KB (+0.34%)
schema-class.ts19.48 KB19.48 KB0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts29.41 KB29.41 KB0.00 KB (0.00%)
schema-representation-roundtrip.ts25.63 KB25.63 KB0.00 KB (0.00%)
schema-string-transformation.ts13.58 KB13.55 KB+0.04 KB (+0.28%)
schema-string.ts11.09 KB11.09 KB0.00 KB (0.00%)
schema-template-literal.ts15.38 KB15.38 KB0.00 KB (0.00%)
schema-toArbitrary.ts21.52 KB21.52 KB0.00 KB (0.00%)
schema-toCodeDocument.ts24.00 KB24.00 KB0.00 KB (0.00%)
schema-toCodecJson.ts18.74 KB18.74 KB0.00 KB (0.00%)
schema-toEquivalence.ts18.57 KB18.57 KB0.00 KB (0.00%)
schema-toFormatter.ts18.43 KB18.43 KB0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts22.59 KB22.59 KB0.00 KB (0.00%)
schema-toRepresentation.ts19.08 KB19.08 KB0.00 KB (0.00%)
schema.ts18.73 KB18.73 KB0.00 KB (0.00%)
stm.ts12.63 KB12.59 KB+0.04 KB (+0.34%)
stream.ts9.67 KB9.67 KB0.00 KB (0.00%)

pullfrog[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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/45Duration.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:mainAug 9, 2026
27 of 30 checks passed
@effect-boteffect-bot mentioned this pull request Aug 9, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rajanpanth@tim-smart
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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:

consta=Duration.seconds(5)constb=Duration.nanos(5_000_000_000n)Duration.equals(a,b)// trueEqual.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-botBot 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
NameType
effectPatch
@effect/ai-anthropicPatch
@effect/ai-openaiPatch
@effect/ai-openai-compatPatch
@effect/ai-openrouterPatch
@effect/atom-reactPatch
@effect/atom-solidPatch
@effect/atom-vuePatch
@effect/docgenPatch
@effect/doctestPatch
@effect/openapi-generatorPatch
@effect/opentelemetryPatch
@effect/platform-browserPatch
@effect/platform-bunPatch
@effect/platform-denoPatch
@effect/platform-nodePatch
@effect/platform-node-sharedPatch
@effect/sql-clickhousePatch
@effect/sql-d1Patch
@effect/sql-libsqlPatch
@effect/sql-mssqlPatch
@effect/sql-mysql2Patch
@effect/sql-pgPatch
@effect/sql-pglitePatch
@effect/sql-sqlite-bunPatch
@effect/sql-sqlite-doPatch
@effect/sql-sqlite-nodePatch
@effect/sql-sqlite-react-nativePatch
@effect/sql-sqlite-wasmPatch
@effect/vitestPatch

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-actionsBot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

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

File NameCurrent SizePrevious SizeDifference
basic.ts6.92 KB6.92 KB0.00 KB (0.00%)
batching.ts9.72 KB9.72 KB0.00 KB (0.00%)
brand.ts6.60 KB6.60 KB0.00 KB (0.00%)
cache.ts10.63 KB10.59 KB+0.04 KB (+0.38%)
config.ts20.91 KB20.91 KB0.00 KB (0.00%)
differ.ts19.77 KB19.77 KB0.00 KB (0.00%)
http-client.ts21.55 KB21.52 KB+0.04 KB (+0.18%)
logger.ts10.84 KB10.81 KB+0.03 KB (+0.31%)
metric.ts8.86 KB8.86 KB0.00 KB (0.00%)
optic.ts6.68 KB6.68 KB0.00 KB (0.00%)
pubsub.ts14.90 KB14.86 KB+0.04 KB (+0.24%)
queue.ts11.57 KB11.54 KB+0.04 KB (+0.31%)
schedule.ts10.74 KB10.71 KB+0.04 KB (+0.34%)
schema-class.ts19.48 KB19.48 KB0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts29.41 KB29.41 KB0.00 KB (0.00%)
schema-representation-roundtrip.ts25.63 KB25.63 KB0.00 KB (0.00%)
schema-string-transformation.ts13.58 KB13.55 KB+0.04 KB (+0.28%)
schema-string.ts11.09 KB11.09 KB0.00 KB (0.00%)
schema-template-literal.ts15.38 KB15.38 KB0.00 KB (0.00%)
schema-toArbitrary.ts21.52 KB21.52 KB0.00 KB (0.00%)
schema-toCodeDocument.ts24.00 KB24.00 KB0.00 KB (0.00%)
schema-toCodecJson.ts18.74 KB18.74 KB0.00 KB (0.00%)
schema-toEquivalence.ts18.57 KB18.57 KB0.00 KB (0.00%)
schema-toFormatter.ts18.43 KB18.43 KB0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts22.59 KB22.59 KB0.00 KB (0.00%)
schema-toRepresentation.ts19.08 KB19.08 KB0.00 KB (0.00%)
schema.ts18.73 KB18.73 KB0.00 KB (0.00%)
stm.ts12.63 KB12.59 KB+0.04 KB (+0.34%)
stream.ts9.67 KB9.67 KB0.00 KB (0.00%)

pullfrog[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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[bot]
pullfrogBot approved these changes Aug 9, 2026

@pullfrogpullfrogBot 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/45Duration.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:mainAug 9, 2026
27 of 30 checks passed
@effect-boteffect-bot mentioned this pull request Aug 9, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rajanpanth@tim-smart