Skip to content

fix(hashset): make copy independent of the original - #4130

Merged
bobzhang merged 1 commit into
mainfrom
hongbo/hashset-copy-fix
Aug 22, 2026
Merged

fix(hashset): make copy independent of the original#4130
bobzhang merged 1 commit into
mainfrom
hongbo/hashset-copy-fix

Conversation

@bobzhang

Copy link
Copy Markdown
Contributor

HashSet::copy allocates a fresh entries array and then blits the old one into it. FixedArray[Entry[K]?] holds references, so both sets come away sharing the same Entry objects — and Entry::psl is mutable, decremented by shift_back whenever a removal shifts a run back.

So a removal on either set silently corrupts the other:

let original = @hashset.HashSet([])
for i in 0..<32 { original.add(i * 8) }   // colliding keys
let duplicate = original.copy()
for i in 0..<32 { duplicate.remove(i * 8) }

original.length()        // 32  — `size` is a separate field per set
// but 6 of the 32 keys are no longer findable by `contains`

Insertions can disturb the original the same way, once one displaces a shared entry.

HashSet was the only container doing this. HashMap::copy, Map::copy and Set::copy all rebuild their entries already; this makes HashSet match them.

How it surfaced

While benchmarking removal for the struct-of-arrays work (#4127), I added a copy-then-remove benchmark and the numbers made no sense — main's copy was implausibly cheap and the removal figures were unstable. Codex CLI, reviewing that PR at ultra effort, worked out why: the benchmark's timed closure was mutating the shared template, so every iteration after the first ran against progressively corrupted state. That invalidated my benchmark, and the reason it was invalid turned out to be a genuine bug underneath.

The struct-of-arrays branch fixes this incidentally, because it rebuilds slots rather than blitting. This commit fixes it on main on its own, so the fix is not gated on that larger and still-contested change.

Test

hashset/copy_test.mbt covers both directions — mutating the copy must not disturb the original, and vice versa — using colliding keys so removal actually reaches shift_back. Without the fix the first test fails with 26 of 32 keys still reachable.

moon test passes 7543/7543 (7484/7484 on js). pkg.generated.mbti unchanged.

Copilot AI lite review requested due to automatic review settings August 22, 2026 07:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes a correctness bug in HashSet::copy where the copy could share mutable Entry objects with the original set, allowing removals/insertions in one set to silently corrupt the other’s probe sequences. This aligns HashSet copy semantics with other containers that rebuild entries rather than blitting references.

Changes:

  • Rebuild HashSet entries during copy() to avoid sharing mutable Entry objects.
  • Add regression tests ensuring mutations on either the original or the copy do not affect the other.

Reviewed changes

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

File Description
hashset/hashset.mbt Replaces reference blit in HashSet::copy with per-slot entry reconstruction to prevent shared mutable state.
hashset/copy_test.mbt Adds regression coverage for copy independence (removals and later insertions/removals).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread hashset/copy_test.mbt
Comment on lines +15 to +39
///|
/// `copy` used to blit the entry references, which left both sets sharing
/// `Entry` objects. Because `Entry::psl` is mutable and `shift_back`
/// decrements it, a removal on either set silently corrupted the other's
/// probe sequences. The keys here are chosen to collide so that removal
/// actually triggers `shift_back`.
test "HashSet::copy is independent of the original" {
let original = @hashset.HashSet([])
for i in 0..<32 {
original.add(i * 8)
}
let duplicate = original.copy()
for i in 0..<32 {
duplicate.remove(i * 8)
}
inspect(duplicate.length(), content="0")
inspect(original.length(), content="32")
let mut still_present = 0
for i in 0..<32 {
if original.contains(i * 8) {
still_present += 1
}
}
inspect(still_present, content="32")
}
@coveralls

coveralls commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 6295

Coverage increased (+0.001%) to 90.877%

Details

  • Coverage increased (+0.001%) from the base build.
  • Patch coverage: 2 of 2 lines across 1 file are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 18447
Covered Lines: 16764
Line Coverage: 90.88%
Coverage Strength: 305750.13 hits per line

💛 - Coveralls

@bobzhang
bobzhang force-pushed the hongbo/hashset-copy-fix branch from 03d8043 to e92b1fa Compare August 22, 2026 08:35
@bobzhang

Copy link
Copy Markdown
Contributor Author

Copilot review addressed

This test assumes i * 8 will reliably produce colliding hashes to exercise shift_back, but Int hashing is mixed, so this may not deterministically create a collision cluster and could miss the regression. Use a local key type with a controlled Hash implementation to guarantee collisions.

Correct, and the fix makes the test strictly better. The tests now use a Collide key type that maps every value into one of eight buckets — the same pattern hashmap/hashmap_coverage_test.mbt already uses, and for the same reason: the runtime hash seed is randomised on some targets, so a test that depends on Int mixing is not a reliable witness.

It also made the failure far sharper. Against the unfixed code:

key type original keys still reachable after emptying the copy
Int (i * 8) 26 of 32
Collide 2 of 32

Self-review

I tried to break this rather than confirm it. What I checked, and what it turned up:

Only one of the three tests is a regression witness. I verified each against the unfixed code: the removal test fails (2 of 32), but the insertion test and the empty-set test pass. Insertion-direction corruption is real — push_away mutates shared entries' psl — but contains on the original cannot observe it here, because the mutated PSL ends up no smaller than the true one and the probe still terminates correctly. I narrowed that test to stay under the growth threshold (growth reassigns every PSL and masks the effect) and it still does not catch the bug. It is kept as a property guard, not claimed as a regression test.

The fix is complete for the type. Entry has exactly psl, hash, key, and all three are rebuilt. Slots are copied position for position, so iteration order is preserved — worth stating, since HashSet iteration order is slot order.

Nothing depended on the sharing. No caller of .copy() inside hashset at all, so no internal behaviour changes.

No sibling has the same defect. I read all three: HashMap::copy rebuilds via Some({ psl, key, value, hash }), and Map::copy / Set::copy build a new_entry per slot while walking their lists. HashSet was the only container blitting, and a search for blit_to across the four containers finds nothing else.

It costs something, and the PR should say so. copy is now ~1.9x slower on native (241 µs → 455 µs) and ~1.5x on js (281 µs → 430 µs) at 50000 elements, because it now actually copies. That is the price of the operation being correct, and it brings HashSet in line with what the other three already pay. I do not think a faster-but-shared variant is worth offering: sharing mutable probe metadata has no safe use.

Where I would still expect trouble: nothing in this diff, but the same class of defect — a container sharing mutable internals through a shallow copy — is worth checking for elsewhere in core. Outside this PR's scope, and I have not swept for it.

moon test: 7544/7544 on native and wasm-gc, 7485/7485 on js. pkg.generated.mbti unchanged.

@bobzhang
bobzhang enabled auto-merge (squash) August 22, 2026 08:36
`HashSet::copy` allocated a fresh `entries` array and then blitted the
old one into it. `FixedArray[Entry[K]?]` holds references, so both sets
came away sharing the same `Entry` objects -- and `Entry::psl` is
mutable, decremented by `shift_back` whenever a removal shifts a run
back.

So a removal on either set silently corrupted the other's probe
sequences. Emptying a copy of a 32-element set leaves all but two of the
original's keys unreachable by `contains`, while `length` still reports
32, because `size` is a separate field on each set.

Rebuild each occupied slot instead, which is what `HashMap::copy`,
`Map::copy` and `Set::copy` already do -- `HashSet` was the only
container blitting. Slots are copied position for position, so the
copy's iteration order still matches the original's.

`copy` gets slower, because it now actually copies: 241 us -> 455 us on
native and 281 us -> 430 us on js for 50000 elements. That is the cost
of the operation being correct, and it brings `HashSet` in line with
what the other three containers already pay.

The regression test uses a key type whose hash maps into eight buckets,
so the collisions that make removal reach `shift_back` are deterministic
rather than dependent on how `Int` hashing happens to mix -- the same
`Collide` pattern `hashmap` already uses for its coverage tests.

Found while benchmarking removal for the struct-of-arrays work: a
copy-then-remove benchmark gave results that could not be explained
until the shallow copy came to light.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bobzhang
bobzhang force-pushed the hongbo/hashset-copy-fix branch from e92b1fa to a558dc6 Compare August 22, 2026 08:39
@bobzhang
bobzhang merged commit aced026 into main Aug 22, 2026
20 checks passed
@bobzhang
bobzhang deleted the hongbo/hashset-copy-fix branch August 22, 2026 08:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants