Skip to content

Commit a93b6d0

Browse files
committed
Fixed potential map corruption bug after deletions
1 parent 00ff206 commit a93b6d0

2 files changed

Lines changed: 79 additions & 24 deletions

File tree

src/map/ShareableMap.ts

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -168,36 +168,50 @@ export class ShareableMap<K, V> extends TransferableDataStructure {
168168
return this.entries();
169169
}
170170

171+
/**
172+
* Returns an iterator over all [key, value] pairs in the map.
173+
*
174+
* **Concurrency note — snapshot iteration:**
175+
* All entries are collected into a private array while a read lock is held, and the lock is
176+
* released before the first value is yielded. This "snapshot" approach is intentional:
177+
*
178+
* - Holding the lock across `yield` points would block writers on other workers for the
179+
* entire duration of the caller's iteration, including any pauses between yields (e.g.
180+
* async work in a `for...of` body).
181+
* - A generator suspended across a yield cannot reliably release the lock if it is abandoned
182+
* (e.g. the caller breaks early without exhausting the iterator), because V8 does not
183+
* guarantee that a generator's `finally` block runs at GC time.
184+
*
185+
* The trade-off is O(n) extra memory for the snapshot. If you do not need lazy iteration and
186+
* want to avoid the allocation, use `forEach()` instead — it holds the read lock for its full
187+
* duration and calls a callback for each entry without building an intermediate collection.
188+
*/
171189
* entries(): MapIterator<[K, V]> {
172-
for (let i = 0; i < this.buckets; i++) {
173-
let dataPointer = this.indexView.getUint32(ShareableMap.INDEX_TABLE_OFFSET + i * ShareableMap.INT_SIZE);
174-
while (dataPointer !== 0) {
175-
const key = this.readTypedKeyFromDataObject(dataPointer);
176-
const value = this.readValueFromDataObject(dataPointer);
177-
yield [key, value];
178-
dataPointer = this.dataView.getUint32(dataPointer);
179-
}
180-
}
190+
const snapshot: [K, V][] = [];
191+
this.forEach((value, key) => snapshot.push([key, value]));
192+
yield* snapshot;
181193
}
182194

195+
/**
196+
* Returns an iterator over all keys in the map.
197+
*
198+
* See `entries()` for a full explanation of the snapshot-based concurrency strategy used here.
199+
*/
183200
* keys(): MapIterator<K> {
184-
for (let i = 0; i < this.buckets; i++) {
185-
let dataPointer = this.indexView.getUint32(ShareableMap.INDEX_TABLE_OFFSET + i * ShareableMap.INT_SIZE);
186-
while (dataPointer !== 0) {
187-
yield this.readTypedKeyFromDataObject(dataPointer);
188-
dataPointer = this.dataView.getUint32(dataPointer);
189-
}
190-
}
201+
const snapshot: K[] = [];
202+
this.forEach((_, key) => snapshot.push(key));
203+
yield* snapshot;
191204
}
192205

206+
/**
207+
* Returns an iterator over all values in the map.
208+
*
209+
* See `entries()` for a full explanation of the snapshot-based concurrency strategy used here.
210+
*/
193211
* values(): MapIterator<V> {
194-
for (let i = 0; i < this.buckets; i++) {
195-
let dataPointer = this.indexView.getUint32(ShareableMap.INDEX_TABLE_OFFSET + i * 4);
196-
while (dataPointer !== 0) {
197-
yield this.readValueFromDataObject(dataPointer);
198-
dataPointer = this.dataView.getUint32(dataPointer);
199-
}
200-
}
212+
const snapshot: V[] = [];
213+
this.forEach((value) => snapshot.push(value));
214+
yield* snapshot;
201215
}
202216

203217
clear(): void {
@@ -248,7 +262,11 @@ export class ShareableMap<K, V> extends TransferableDataStructure {
248262
} else {
249263
let previousBlock = bucketLink;
250264
let currentBlock = this.dataView.getUint32(bucketLink);
251-
while (this.dataView.getUint32(currentBlock + 16) !== hash) {
265+
// Walk the chain by position, not by hash. Using the stored hash to find the
266+
// predecessor is incorrect when two distinct keys share the same FNV-1a hash
267+
// (a full 32-bit collision): the loop would stop at the first colliding node
268+
// and re-link the wrong predecessor, silently dropping an unrelated entry.
269+
while (currentBlock !== startPos) {
252270
previousBlock = currentBlock;
253271
currentBlock = this.dataView.getUint32(currentBlock);
254272
}

src/map/__tests__/ShareableMap.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,43 @@ describe("ShareableMap", () => {
205205
}
206206
});
207207

208+
it("should correctly iterate entries, keys, and values", () => {
209+
const map = new ShareableMap<string, number>();
210+
map.set("a", 1);
211+
map.set("b", 2);
212+
map.set("c", 3);
213+
214+
expect([...map.keys()].toSorted()).toEqual(["a", "b", "c"]);
215+
expect([...map.values()].toSorted((x, y) => x - y)).toEqual([1, 2, 3]);
216+
expect([...map.entries()].map(([k]) => k).toSorted()).toEqual(["a", "b", "c"]);
217+
});
218+
219+
it("should correctly delete a non-head entry that shares a bucket with other entries", () => {
220+
// A small initial bucket count guarantees many hash-bucket collisions, exercising
221+
// the predecessor-search path in deleteItem() for non-head chain nodes.
222+
const map = new ShareableMap<string, number>({ expectedSize: 4, averageBytesPerValue: 64 });
223+
224+
const entries: [string, number][] = [];
225+
for (let i = 0; i < 32; i++) {
226+
entries.push([`collision-key-${i}`, i]);
227+
map.set(`collision-key-${i}`, i);
228+
}
229+
230+
// Delete every other entry and verify the map stays consistent.
231+
for (let i = 0; i < entries.length; i += 2) {
232+
expect(map.delete(entries[i][0])).toBe(true);
233+
}
234+
235+
for (let i = 0; i < entries.length; i++) {
236+
const [key, value] = entries[i];
237+
if (i % 2 === 0) {
238+
expect(map.has(key)).toBe(false);
239+
} else {
240+
expect(map.get(key)).toBe(value);
241+
}
242+
}
243+
});
244+
208245
it("should preserve all entries after doubleDataStorage and doubleIndexStorage are triggered", () => {
209246
// Small initial size (4 entries × 64 bytes = 256 byte data buffer, 4-bucket index) so both
210247
// growth paths are exercised within the first few hundred insertions.

0 commit comments

Comments
 (0)