Skip to content

Commit cecf2d8

Browse files
authored
Fix RefCountedSet issue(s) (#3093)
Fixes #2497 While investigating the issue I added an integrity check that found a problem hiding in the insert logic that was unrelated- in fixing that I greatly simplified the insert logic. It turns out that #2497 is ultimately just a case of bad luck, pathological inputs that result in very non-uniform hashes so the clustering overwhelms things. The solution was just to add a check and claim we're out of memory. I tried adding an entropy folding function to fix the hash a little but it had a measurable negative impact on performance and isn't necessary so I've not included it here. Currently there's an open PR to Zig to [add RapidHash](ziglang/zig#22085), which is the successor to Wyhash and apparently has much better statistical characteristics on top of being faster. I imagine it will land in time for 0.14 so whenever we update to 0.14 we should probably switch our standard hash function to RapidHash, which I imagine should yield improvements across the board. Using the AutoHasher may also be not the best idea, I may explore ways to improve how we generate our style hashes in the future.
2 parents 0f7a089 + cb60f9d commit cecf2d8

File tree

2 files changed

+100
-26
lines changed

2 files changed

+100
-26
lines changed

src/terminal/Screen.zig

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1763,10 +1763,15 @@ pub fn manualStyleUpdate(self: *Screen) !void {
17631763

17641764
// If our new style is the default, just reset to that
17651765
if (self.cursor.style.default()) {
1766-
self.cursor.style_id = 0;
1766+
self.cursor.style_id = style.default_id;
17671767
return;
17681768
}
17691769

1770+
// Clear the cursor style ID to prevent weird things from happening
1771+
// if the page capacity has to be adjusted which would end up calling
1772+
// manualStyleUpdate again.
1773+
self.cursor.style_id = style.default_id;
1774+
17701775
// After setting the style, we need to update our style map.
17711776
// Note that we COULD lazily do this in print. We should look into
17721777
// if that makes a meaningful difference. Our priority is to keep print

src/terminal/ref_counted_set.zig

Lines changed: 94 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ pub fn RefCountedSet(
103103
/// unlikely. Roughly a (1/table_cap)^32 -- with any normal
104104
/// table capacity that is so unlikely that it's not worth
105105
/// handling.
106+
///
107+
/// However, that assumes a uniform hash function, which
108+
/// is not guaranteed and can be subverted with a crafted
109+
/// input. We handle this gracefully by returning an error
110+
/// anywhere where we're about to insert if there's any
111+
/// item with a PSL in the last slot of the stats array.
106112
psl_stats: [32]Id = [_]Id{0} ** 32,
107113

108114
/// The backing store of items
@@ -237,6 +243,16 @@ pub fn RefCountedSet(
237243
return id;
238244
}
239245

246+
// While it should be statistically impossible to exceed the
247+
// bounds of `psl_stats`, the hash function is not perfect and
248+
// in such a case we want to remain stable. If we're about to
249+
// insert an item and there's something with a PSL of `len - 1`,
250+
// we may end up with a PSL of `len` which would exceed the bounds.
251+
// In such a case, we claim to be out of memory.
252+
if (self.psl_stats[self.psl_stats.len - 1] > 0) {
253+
return AddError.OutOfMemory;
254+
}
255+
240256
// If the item doesn't exist, we need an available ID.
241257
if (self.next_id >= self.layout.cap) {
242258
// Arbitrarily chosen, threshold for rehashing.
@@ -284,6 +300,11 @@ pub fn RefCountedSet(
284300

285301
if (id < self.next_id) {
286302
if (items[id].meta.ref == 0) {
303+
// See comment in `addContext` for details.
304+
if (self.psl_stats[self.psl_stats.len - 1] > 0) {
305+
return AddError.OutOfMemory;
306+
}
307+
287308
self.deleteItem(base, id, ctx);
288309

289310
const added_id = self.upsert(base, value, id, ctx);
@@ -419,7 +440,7 @@ pub fn RefCountedSet(
419440

420441
if (item.meta.bucket > self.layout.table_cap) return;
421442

422-
if (table[item.meta.bucket] != id) return;
443+
assert(table[item.meta.bucket] == id);
423444

424445
if (comptime @hasDecl(Context, "deleted")) {
425446
// Inform the context struct that we're
@@ -449,6 +470,8 @@ pub fn RefCountedSet(
449470
}
450471

451472
table[p] = 0;
473+
474+
self.assertIntegrity(base, ctx);
452475
}
453476

454477
/// Find an item in the table and return its ID.
@@ -463,7 +486,7 @@ pub fn RefCountedSet(
463486
const hash: u64 = ctx.hash(value);
464487

465488
for (0..self.max_psl + 1) |i| {
466-
const p: usize = @intCast((hash + i) & self.layout.table_mask);
489+
const p: usize = @intCast((hash +% i) & self.layout.table_mask);
467490
const id = table[p];
468491

469492
// Empty bucket, our item cannot have probed to
@@ -538,11 +561,10 @@ pub fn RefCountedSet(
538561
var held_id: Id = new_id;
539562
var held_item: *Item = &new_item;
540563

541-
var chosen_p: ?Id = null;
542564
var chosen_id: Id = new_id;
543565

544566
for (0..self.layout.table_cap - 1) |i| {
545-
const p: Id = @intCast((hash + i) & self.layout.table_mask);
567+
const p: Id = @intCast((hash +% i) & self.layout.table_mask);
546568
const id = table[p];
547569

548570
// Empty bucket, put our held item in to it and break.
@@ -557,48 +579,43 @@ pub fn RefCountedSet(
557579
const item = &items[id];
558580

559581
// If there's a dead item then we resurrect it
560-
// for our value so that we can re-use its ID.
582+
// for our value so that we can re-use its ID,
583+
// unless its ID is greater than the one we're
584+
// given (i.e. prefer smaller IDs).
561585
if (item.meta.ref == 0) {
562586
if (comptime @hasDecl(Context, "deleted")) {
563587
// Inform the context struct that we're
564588
// deleting the dead item's value for good.
565589
ctx.deleted(item.value);
566590
}
567591

568-
chosen_id = id;
592+
// Reap the dead item.
593+
self.psl_stats[item.meta.psl] -= 1;
594+
item.* = .{};
595+
596+
// Only resurrect this item if it has a
597+
// smaller id than the one we were given.
598+
if (id < new_id) chosen_id = id;
569599

600+
// Put the currently held item in to the
601+
// bucket of the item that we just reaped.
602+
table[p] = held_id;
570603
held_item.meta.bucket = p;
571-
self.psl_stats[item.meta.psl] -= 1;
572604
self.psl_stats[held_item.meta.psl] += 1;
573605
self.max_psl = @max(self.max_psl, held_item.meta.psl);
574606

575-
// If we're not still holding our new item then we
576-
// need to make sure that we put the re-used ID in
577-
// the right place, where we previously put new_id.
578-
if (chosen_p) |c| {
579-
table[c] = id;
580-
table[p] = held_id;
581-
} else {
582-
// If we're still holding our new item then we
583-
// don't actually have to do anything, because
584-
// the table already has the correct ID here.
585-
}
586-
587607
break;
588608
}
589609

590610
// This item has a lower PSL, swap it out with our held item.
591611
if (item.meta.psl < held_item.meta.psl) {
592-
if (held_id == new_id) {
593-
chosen_p = p;
594-
new_item.meta.bucket = p;
595-
}
596-
612+
// Put our held item in the bucket.
597613
table[p] = held_id;
598-
items[held_id].meta.bucket = p;
614+
held_item.meta.bucket = p;
599615
self.psl_stats[held_item.meta.psl] += 1;
600616
self.max_psl = @max(self.max_psl, held_item.meta.psl);
601617

618+
// Pick up the item that has a lower PSL.
602619
held_id = id;
603620
held_item = item;
604621
self.psl_stats[item.meta.psl] -= 1;
@@ -608,8 +625,60 @@ pub fn RefCountedSet(
608625
held_item.meta.psl += 1;
609626
}
610627

628+
// Our chosen ID may have changed if we decided
629+
// to re-use a dead item's ID, so we make sure
630+
// the chosen bucket contains the correct ID.
631+
table[new_item.meta.bucket] = chosen_id;
632+
633+
// Finally place our new item in to our array.
611634
items[chosen_id] = new_item;
635+
636+
self.assertIntegrity(base, ctx);
637+
612638
return chosen_id;
613639
}
640+
641+
fn assertIntegrity(
642+
self: *const Self,
643+
base: anytype,
644+
ctx: Context,
645+
) void {
646+
// Disabled because this is excessively slow, only enable
647+
// if debugging a RefCountedSet issue or modifying its logic.
648+
if (false and std.debug.runtime_safety) {
649+
const table = self.table.ptr(base);
650+
const items = self.items.ptr(base);
651+
652+
var psl_stats: [32]Id = [_]Id{0} ** 32;
653+
654+
for (items[0..self.layout.cap], 0..) |item, id| {
655+
if (item.meta.bucket < std.math.maxInt(Id)) {
656+
assert(table[item.meta.bucket] == id);
657+
psl_stats[item.meta.psl] += 1;
658+
}
659+
}
660+
661+
std.testing.expectEqualSlices(Id, &psl_stats, &self.psl_stats) catch assert(false);
662+
663+
assert(std.mem.eql(Id, &psl_stats, &self.psl_stats));
664+
665+
psl_stats = [_]Id{0} ** 32;
666+
667+
for (table[0..self.layout.table_cap], 0..) |id, bucket| {
668+
const item = items[id];
669+
if (item.meta.bucket < std.math.maxInt(Id)) {
670+
assert(item.meta.bucket == bucket);
671+
672+
const hash: u64 = ctx.hash(item.value);
673+
const p: usize = @intCast((hash +% item.meta.psl) & self.layout.table_mask);
674+
assert(p == bucket);
675+
676+
psl_stats[item.meta.psl] += 1;
677+
}
678+
}
679+
680+
std.testing.expectEqualSlices(Id, &psl_stats, &self.psl_stats) catch assert(false);
681+
}
682+
}
614683
};
615684
}

0 commit comments

Comments
 (0)