diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 6681b733e..4d1ac5d2c 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1158,17 +1158,17 @@ fn reflowPage( // If the source cell has a style, we need to copy it. if (src_cursor.page_cell.style_id != stylepkg.default_id) { - const src_style = src_cursor.page.styles.lookupId( + const src_style = src_cursor.page.styles.get( src_cursor.page.memory, src_cursor.page_cell.style_id, - ).?.*; - - const dst_md = try dst_cursor.page.styles.upsert( + ).*; + if (try dst_cursor.page.styles.addWithId( dst_cursor.page.memory, src_style, - ); - dst_md.ref += 1; - dst_cursor.page_cell.style_id = dst_md.id; + src_cursor.page_cell.style_id, + )) |id| { + dst_cursor.page_cell.style_id = id; + } dst_cursor.page_row.styled = true; } } @@ -1905,18 +1905,6 @@ pub fn adjustCapacity( return new_page; } -/// Compact a page, reallocating to minimize the amount of memory -/// required for the page. This is useful when we've overflowed ID -/// spaces, are archiving a page, etc. -/// -/// Note today: this doesn't minimize the memory usage, but it does -/// fix style ID overflow. A future update can shrink the memory -/// allocation. -pub fn compact(self: *PageList, page: *List.Node) !*List.Node { - // Adjusting capacity with no adjustments forces a reallocation. - return try self.adjustCapacity(page, .{}); -} - /// Create a new page node. This does not add it to the list and this /// does not do any memory size accounting with max_size/page_size. fn createPage( @@ -3039,10 +3027,10 @@ pub const Pin = struct { /// Returns the style for the given cell in this pin. pub fn style(self: Pin, cell: *pagepkg.Cell) stylepkg.Style { if (cell.style_id == stylepkg.default_id) return .{}; - return self.page.data.styles.lookupId( + return self.page.data.styles.get( self.page.data.memory, cell.style_id, - ).?.*; + ).*; } /// Check if this pin is dirty. @@ -3302,10 +3290,10 @@ const Cell = struct { /// Not meant for non-test usage since this is inefficient. pub fn style(self: Cell) stylepkg.Style { if (self.cell.style_id == stylepkg.default_id) return .{}; - return self.page.data.styles.lookupId( + return self.page.data.styles.get( self.page.data.memory, self.cell.style_id, - ).?.*; + ).*; } /// Gets the screen point for the given cell. @@ -7363,18 +7351,20 @@ test "PageList resize reflow less cols copy style" { // Create a style const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; - const style_md = try page.styles.upsert(page.memory, style); + const style_id = try page.styles.add(page.memory, style); for (0..s.cols - 1) |x| { const rac = page.getRowAndCell(x, 0); rac.cell.* = .{ .content_tag = .codepoint, .content = .{ .codepoint = @intCast(x) }, - .style_id = style_md.id, + .style_id = style_id, }; - - style_md.ref += 1; + page.styles.use(page.memory, style_id); } + + // We're over-counted by 1 because `add` implies `use`. + page.styles.release(page.memory, style_id); } // Resize @@ -7391,10 +7381,10 @@ test "PageList resize reflow less cols copy style" { const style_id = rac.cell.style_id; try testing.expect(style_id != 0); - const style = offset.page.data.styles.lookupId( + const style = offset.page.data.styles.get( offset.page.data.memory, style_id, - ).?; + ); try testing.expect(style.flags.bold); const row = rac.row; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index df00546fd..cbe278ec5 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -100,7 +100,6 @@ pub const Cursor = struct { /// we change pages we need to ensure that we update that page with /// our style when used. style_id: style.Id = style.default_id, - style_ref: ?*size.CellCountInt = null, /// The pointers into the page list where the cursor is currently /// located. This makes it faster to move the cursor. @@ -202,31 +201,6 @@ pub fn assertIntegrity(self: *const Screen) void { ) orelse unreachable; assert(self.cursor.x == pt.active.x); assert(self.cursor.y == pt.active.y); - - if (self.cursor.style_id == style.default_id) { - // If our style is default, we should have no refs. - assert(self.cursor.style.default()); - assert(self.cursor.style_ref == null); - } else { - // If our style is not default, we should have a ref. - assert(!self.cursor.style.default()); - assert(self.cursor.style_ref != null); - - // Further, the ref should be valid within the current page. - const page = &self.cursor.page_pin.page.data; - const page_style = page.styles.lookupId( - page.memory, - self.cursor.style_id, - ).?.*; - assert(self.cursor.style.eql(page_style)); - - // The metadata pointer should be equal to the ref. - const md = page.styles.upsert( - page.memory, - page_style, - ) catch unreachable; - assert(&md.ref == self.cursor.style_ref.?); - } } } @@ -524,14 +498,6 @@ pub fn cursorReload(self: *Screen) void { // If we have a style, we need to ensure it is in the page because this // method may also be called after a page change. if (self.cursor.style_id != style.default_id) { - // We set our ref to null because manualStyleUpdate will refresh it. - // If we had a valid ref and it was zero before, then manualStyleUpdate - // will reload the same ref. - // - // We want to avoid the scenario this was non-null but the pointer - // is now invalid because it pointed to a page that no longer exists. - self.cursor.style_ref = null; - self.manualStyleUpdate() catch |err| { // This failure should not happen because manualStyleUpdate // handles page splitting, overflow, and more. This should only @@ -540,7 +506,6 @@ pub fn cursorReload(self: *Screen) void { log.err("failed to update style on cursor reload err={}", .{err}); self.cursor.style = .{}; self.cursor.style_id = 0; - self.cursor.style_ref = null; }; } } @@ -595,7 +560,6 @@ pub fn cursorDownScroll(self: *Screen) !void { log.err("failed to update style on cursor scroll err={}", .{err}); self.cursor.style = .{}; self.cursor.style_id = 0; - self.cursor.style_ref = null; }; } } else { @@ -683,7 +647,6 @@ pub fn cursorCopy(self: *Screen, other: Cursor) !void { // invalid. self.cursor.style = .{}; self.cursor.style_id = 0; - self.cursor.style_ref = null; // We need to keep our old x/y because that is our cursorAbsolute // will fix up our pointers. @@ -698,7 +661,6 @@ pub fn cursorCopy(self: *Screen, other: Cursor) !void { // We keep the old style ref so manualStyleUpdate can clean our old style up. self.cursor.style = other.style; self.cursor.style_id = old.style_id; - self.cursor.style_ref = old.style_ref; try self.manualStyleUpdate(); } @@ -744,7 +706,6 @@ fn cursorChangePin(self: *Screen, new: Pin) void { log.err("failed to update style on cursor change err={}", .{err}); self.cursor.style = .{}; self.cursor.style_id = 0; - self.cursor.style_ref = null; }; } @@ -889,33 +850,7 @@ pub fn clearCells( if (row.styled) { for (cells) |*cell| { if (cell.style_id == style.default_id) continue; - - // Fast-path, the style ID matches, in this case we just update - // our own ref and continue. We never delete because our style - // is still active. - if (page == &self.cursor.page_pin.page.data and - cell.style_id == self.cursor.style_id) - { - self.cursor.style_ref.?.* -= 1; - continue; - } - - // Slow path: we need to lookup this style so we can decrement - // the ref count. Since we've already loaded everything, we also - // just go ahead and GC it if it reaches zero, too. - if (page.styles.lookupId( - page.memory, - cell.style_id, - )) |prev_style| { - // Below upsert can't fail because it should already be present - const md = page.styles.upsert( - page.memory, - prev_style.*, - ) catch unreachable; - assert(md.ref > 0); - md.ref -= 1; - if (md.ref == 0) page.styles.remove(page.memory, cell.style_id); - } + page.styles.release(page.memory, cell.style_id); } // If we have no left/right scroll region we can be sure that @@ -1044,17 +979,37 @@ pub fn resizeWithoutReflow( } /// Resize the screen. -// TODO: replace resize and resizeWithoutReflow with this. fn resizeInternal( self: *Screen, cols: size.CellCountInt, rows: size.CellCountInt, reflow: bool, ) !void { + defer self.assertIntegrity(); + // No matter what we mark our image state as dirty self.kitty_images.dirty = true; - // Perform the resize operation. This will update cursor by reference. + // Release the cursor style while resizing just + // in case the cursor ends up on a different page. + const cursor_style = self.cursor.style; + self.cursor.style = .{}; + self.manualStyleUpdate() catch unreachable; + defer { + // Restore the cursor style. + self.cursor.style = cursor_style; + self.manualStyleUpdate() catch |err| { + // This failure should not happen because manualStyleUpdate + // handles page splitting, overflow, and more. This should only + // happen if we're out of RAM. In this case, we'll just degrade + // gracefully back to the default style. + log.err("failed to update style on cursor reload err={}", .{err}); + self.cursor.style = .{}; + self.cursor.style_id = 0; + }; + } + + // Perform the resize operation. try self.pages.resize(.{ .rows = rows, .cols = cols, @@ -1072,7 +1027,6 @@ fn resizeInternal( // If our cursor was updated, we do a full reload so all our cursor // state is correct. self.cursorReload(); - self.assertIntegrity(); } /// Set a style attribute for the current cursor. @@ -1223,21 +1177,14 @@ pub fn manualStyleUpdate(self: *Screen) !void { // std.log.warn("active styles={}", .{page.styles.count(page.memory)}); - // Remove our previous style if is unused. - if (self.cursor.style_ref) |ref| { - if (ref.* == 0) { - page.styles.remove(page.memory, self.cursor.style_id); - } - - // Reset our ID and ref to null since the ref is now invalid. - self.cursor.style_id = 0; - self.cursor.style_ref = null; + // Release our previous style if it was not default. + if (self.cursor.style_id != style.default_id) { + page.styles.release(page.memory, self.cursor.style_id); } // If our new style is the default, just reset to that if (self.cursor.style.default()) { self.cursor.style_id = 0; - self.cursor.style_ref = null; return; } @@ -1251,42 +1198,29 @@ pub fn manualStyleUpdate(self: *Screen) !void { // if that makes a meaningful difference. Our priority is to keep print // fast because setting a ton of styles that do nothing is uncommon // and weird. - const md = page.styles.upsert( + const id = page.styles.add( page.memory, self.cursor.style, - ) catch |err| md: { - switch (err) { - // Our style map is full. Let's allocate a new page by doubling - // the size and then try again. - error.OutOfMemory => { - const node = try self.pages.adjustCapacity( - self.cursor.page_pin.page, - .{ .styles = page.capacity.styles * 2 }, - ); + ) catch id: { + // Our style map is full. Let's allocate a new + // page by doubling the size and then try again. + const node = try self.pages.adjustCapacity( + self.cursor.page_pin.page, + .{ .styles = page.capacity.styles * 2 }, + ); - page = &node.data; - }, - - // We've run out of style IDs. This is fixed by doing a page - // compaction. - error.Overflow => { - const node = try self.pages.compact( - self.cursor.page_pin.page, - ); - page = &node.data; - }, - } + page = &node.data; // Since this modifies our cursor page, we need to reload cursor_reload = true; - break :md try page.styles.upsert( + break :id try page.styles.add( page.memory, self.cursor.style, ); }; - self.cursor.style_id = md.id; - self.cursor.style_ref = &md.ref; + self.cursor.style_id = id; + if (cursor_reload) self.cursorReload(); self.assertIntegrity(); } @@ -2271,8 +2205,9 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { }; // If we have a ref-counted style, increase. - if (self.cursor.style_ref) |ref| { - ref.* += 1; + if (self.cursor.style_id != style.default_id) { + const page = self.cursor.page_pin.page.data; + page.styles.use(page.memory, self.cursor.style_id); self.cursor.page_row.styled = true; } }, @@ -2310,6 +2245,14 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { .wide = .spacer_tail, .protected = self.cursor.protected, }; + + // If we have a ref-counted style, increase twice. + if (self.cursor.style_id != style.default_id) { + const page = self.cursor.page_pin.page.data; + page.styles.use(page.memory, self.cursor.style_id); + page.styles.use(page.memory, self.cursor.style_id); + self.cursor.page_row.styled = true; + } }, else => unreachable, @@ -2792,10 +2735,10 @@ test "Screen: cursorDown across pages preserves style" { try s.setAttribute(.{ .bold = {} }); { const page = &s.cursor.page_pin.page.data; - const styleval = page.styles.lookupId( + const styleval = page.styles.get( page.memory, s.cursor.style_id, - ).?; + ); try testing.expect(styleval.flags.bold); } @@ -2803,10 +2746,10 @@ test "Screen: cursorDown across pages preserves style" { s.cursorDown(1); { const page = &s.cursor.page_pin.page.data; - const styleval = page.styles.lookupId( + const styleval = page.styles.get( page.memory, s.cursor.style_id, - ).?; + ); try testing.expect(styleval.flags.bold); } } @@ -2835,10 +2778,10 @@ test "Screen: cursorUp across pages preserves style" { try s.setAttribute(.{ .bold = {} }); { const page = &s.cursor.page_pin.page.data; - const styleval = page.styles.lookupId( + const styleval = page.styles.get( page.memory, s.cursor.style_id, - ).?; + ); try testing.expect(styleval.flags.bold); } @@ -2848,10 +2791,10 @@ test "Screen: cursorUp across pages preserves style" { const page = &s.cursor.page_pin.page.data; try testing.expect(start_page == page); - const styleval = page.styles.lookupId( + const styleval = page.styles.get( page.memory, s.cursor.style_id, - ).?; + ); try testing.expect(styleval.flags.bold); } } @@ -2880,10 +2823,10 @@ test "Screen: cursorAbsolute across pages preserves style" { try s.setAttribute(.{ .bold = {} }); { const page = &s.cursor.page_pin.page.data; - const styleval = page.styles.lookupId( + const styleval = page.styles.get( page.memory, s.cursor.style_id, - ).?; + ); try testing.expect(styleval.flags.bold); } @@ -2893,10 +2836,10 @@ test "Screen: cursorAbsolute across pages preserves style" { const page = &s.cursor.page_pin.page.data; try testing.expect(start_page == page); - const styleval = page.styles.lookupId( + const styleval = page.styles.get( page.memory, s.cursor.style_id, - ).?; + ); try testing.expect(styleval.flags.bold); } } @@ -3013,10 +2956,10 @@ test "Screen: scrolling across pages preserves style" { const page = &s.pages.pages.last.?.data; try testing.expect(start_page != page); - const styleval = page.styles.lookupId( + const styleval = page.styles.get( page.memory, s.cursor.style_id, - ).?; + ); try testing.expect(styleval.flags.bold); } diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index b2783ba82..09223aeeb 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -600,8 +600,19 @@ fn printCell( ); } - // Keep track of the previous style so we can decrement the ref count - const prev_style_id = cell.style_id; + // We don't need to update the style refs unless the + // cell's new style will be different after writing. + const style_changed = cell.style_id != self.screen.cursor.style_id; + + if (style_changed) { + var page = &self.screen.cursor.page_pin.page.data; + + // Release the old style. + if (cell.style_id != style.default_id) { + assert(self.screen.cursor.page_row.styled); + page.styles.release(page.memory, cell.style_id); + } + } // Write cell.* = .{ @@ -612,50 +623,12 @@ fn printCell( .protected = self.screen.cursor.protected, }; - if (comptime std.debug.runtime_safety) { - // We've had bugs around this, so let's add an assertion: every - // style we use should be present in the style table. - if (self.screen.cursor.style_id != style.default_id) { - const page = &self.screen.cursor.page_pin.page.data; - if (page.styles.lookupId( - page.memory, - self.screen.cursor.style_id, - ) == null) { - log.err("can't find style page={X} id={}", .{ - @intFromPtr(&self.screen.cursor.page_pin.page.data), - self.screen.cursor.style_id, - }); - @panic("style not found"); - } - } - } + if (style_changed) { + var page = &self.screen.cursor.page_pin.page.data; - // Handle the style ref count handling - style_ref: { - if (prev_style_id != style.default_id) { - const row = self.screen.cursor.page_row; - assert(row.styled); - - // If our previous cell had the same style ID as us currently, - // then we don't bother with any ref counts because we're the same. - if (prev_style_id == self.screen.cursor.style_id) break :style_ref; - - // Slow path: we need to lookup this style so we can decrement - // the ref count. Since we've already loaded everything, we also - // just go ahead and GC it if it reaches zero, too. - var page = &self.screen.cursor.page_pin.page.data; - if (page.styles.lookupId(page.memory, prev_style_id)) |prev_style| { - // Below upsert can't fail because it should already be present - const md = page.styles.upsert(page.memory, prev_style.*) catch unreachable; - assert(md.ref > 0); - md.ref -= 1; - if (md.ref == 0) page.styles.remove(page.memory, prev_style_id); - } - } - - // If we have a ref-counted style, increase. - if (self.screen.cursor.style_ref) |ref| { - ref.* += 1; + // Use the new style. + if (cell.style_id != style.default_id) { + page.styles.use(page.memory, cell.style_id); self.screen.cursor.page_row.styled = true; } } @@ -2190,8 +2163,12 @@ pub fn decaln(self: *Terminal) !void { }); // If we have a ref-counted style, increase - if (self.screen.cursor.style_ref) |ref| { - ref.* += @intCast(cells.len); + if (self.screen.cursor.style_id != style.default_id) { + page.styles.useMultiple( + page.memory, + self.screen.cursor.style_id, + @intCast(cells.len), + ); row.styled = true; } @@ -7066,7 +7043,8 @@ test "Terminal: bold style" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); try testing.expect(cell.style_id != 0); - try testing.expect(t.screen.cursor.style_ref.?.* > 0); + const page = t.screen.cursor.page_pin.page.data; + try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 1); } } diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 7137431cf..b14632e46 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -197,6 +197,7 @@ pub const Page = struct { .styles = style.Set.init( buf.add(l.styles_start), l.styles_layout, + style.StyleSetContext{}, ), .grapheme_alloc = GraphemeAlloc.init( buf.add(l.grapheme_alloc_start), @@ -324,17 +325,11 @@ pub const Page = struct { if (cell.style_id != style.default_id) { // If a cell has a style, it must be present in the styles - // set. - _ = self.styles.lookupId( + // set. Accessing it with `get` asserts that. + _ = self.styles.get( self.memory, cell.style_id, - ) orelse { - log.warn( - "page integrity violation y={} x={} style missing id={}", - .{ y, x, cell.style_id }, - ); - return IntegrityError.MissingStyle; - }; + ); if (!row.styled) { log.warn( @@ -424,12 +419,11 @@ pub const Page = struct { { var it = styles_seen.iterator(); while (it.next()) |entry| { - const style_val = self.styles.lookupId(self.memory, entry.key_ptr.*).?.*; - const md = self.styles.upsert(self.memory, style_val) catch unreachable; - if (md.ref < entry.value_ptr.*) { + const ref_count = self.styles.refCount(self.memory, entry.key_ptr.*); + if (ref_count < entry.value_ptr.*) { log.warn( "page integrity violation style ref count mismatch id={} expected={} actual={}", - .{ entry.key_ptr.*, entry.value_ptr.*, md.ref }, + .{ entry.key_ptr.*, entry.value_ptr.*, ref_count }, ); return IntegrityError.MismatchedStyleRef; } @@ -474,7 +468,7 @@ pub const Page = struct { return result; } - pub const CloneFromError = Allocator.Error || style.Set.UpsertError; + pub const CloneFromError = Allocator.Error || error{OutOfMemory}; /// Clone the contents of another page into this page. The capacities /// can be different, but the size of the other page must fit into @@ -586,11 +580,22 @@ pub const Page = struct { for (cps) |cp| try self.appendGrapheme(dst_row, dst_cell, cp); } if (src_cell.style_id != style.default_id) { - const other_style = other.styles.lookupId(other.memory, src_cell.style_id).?.*; - const md = try self.styles.upsert(self.memory, other_style); - md.ref += 1; - dst_cell.style_id = md.id; dst_row.styled = true; + + if (other == self) { + // If it's the same page we don't have to worry about + // copying the style, we can use the style ID directly. + dst_cell.style_id = src_cell.style_id; + self.styles.use(self.memory, dst_cell.style_id); + continue; + } + + // Slow path: Get the style from the other + // page and add it to this page's style set. + const other_style = other.styles.get(other.memory, src_cell.style_id).*; + if (try self.styles.addWithId(self.memory, other_style, src_cell.style_id)) |id| { + dst_cell.style_id = id; + } } } } @@ -767,13 +772,7 @@ pub const Page = struct { for (cells) |*cell| { if (cell.style_id == style.default_id) continue; - if (self.styles.lookupId(self.memory, cell.style_id)) |prev_style| { - // Below upsert can't fail because it should already be present - const md = self.styles.upsert(self.memory, prev_style.*) catch unreachable; - assert(md.ref > 0); - md.ref -= 1; - if (md.ref == 0) self.styles.remove(self.memory, cell.style_id); - } + self.styles.release(self.memory, cell.style_id); } if (cells.len == self.size.cols) row.styled = false; @@ -2112,7 +2111,7 @@ test "Page verifyIntegrity styles good" { defer page.deinit(); // Upsert a style we'll use - const md = try page.styles.upsert(page.memory, .{ .flags = .{ + const id = try page.styles.add(page.memory, .{ .flags = .{ .bold = true, } }); @@ -2123,11 +2122,15 @@ test "Page verifyIntegrity styles good" { rac.cell.* = .{ .content_tag = .codepoint, .content = .{ .codepoint = @intCast(x + 1) }, - .style_id = md.id, + .style_id = id, }; - md.ref += 1; + page.styles.use(page.memory, id); } + // The original style add would have incremented the + // ref count too, so release it to balance that out. + page.styles.release(page.memory, id); + try page.verifyIntegrity(testing.allocator); } @@ -2140,7 +2143,7 @@ test "Page verifyIntegrity styles ref count mismatch" { defer page.deinit(); // Upsert a style we'll use - const md = try page.styles.upsert(page.memory, .{ .flags = .{ + const id = try page.styles.add(page.memory, .{ .flags = .{ .bold = true, } }); @@ -2151,13 +2154,17 @@ test "Page verifyIntegrity styles ref count mismatch" { rac.cell.* = .{ .content_tag = .codepoint, .content = .{ .codepoint = @intCast(x + 1) }, - .style_id = md.id, + .style_id = id, }; - md.ref += 1; + page.styles.use(page.memory, id); } + // The original style add would have incremented the + // ref count too, so release it to balance that out. + page.styles.release(page.memory, id); + // Miss a ref - md.ref -= 1; + page.styles.release(page.memory, id); try testing.expectError( Page.IntegrityError.MismatchedStyleRef, diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig new file mode 100644 index 000000000..19d938cfc --- /dev/null +++ b/src/terminal/ref_counted_set.zig @@ -0,0 +1,573 @@ +const size = @import("size.zig"); +const Offset = size.Offset; +const OffsetBuf = size.OffsetBuf; + +const fastmem = @import("../fastmem.zig"); + +const std = @import("std"); +const assert = std.debug.assert; + +/// A reference counted set. +/// +/// This set is created with some capacity in mind. You can determine +/// the exact memory requirement of a given capacity by calling `layout` +/// and checking the total size. +/// +/// When the set exceeds capacity, `error.OutOfMemory` is returned from +/// any memory-using methods. The caller is responsible for determining +/// a path forward. +/// +/// This set is reference counted. Each item in the set has an associated +/// reference count. The caller is responsible for calling release for an +/// item when it is no longer being used. Items with 0 references will be +/// kept until another item is written to their bucket. This allows items +/// to be ressurected if they are re-added before they get overwritten. +/// +/// The backing data structure of this set is an open addressed hash table +/// with linear probing and Robin Hood hashing, and a flat array of items. +/// +/// The table maps values to item IDs, which are indices in the item array +/// which contain the item's value and its reference count. Item IDs can be +/// used to efficiently access an item and update its reference count after +/// it has been added to the table, to avoid having to use the hash map to +/// look the value back up. +/// +/// ID 0 is reserved and will never be assigned. +/// +/// Parameters: +/// +/// `Context` +/// A type containing methods to define behaviors. +/// - `fn hash(*Context, T) u64` - Return a hash for an item. +/// - `fn eql(*Context, T, T) bool` - Check two items for equality. +/// +/// - `fn deleted(*Context, T) void` - [OPTIONAL] Deletion callback. +/// If present, called whenever an item is finally deleted. +/// Useful if the item has memory that needs to be freed. +/// +pub fn RefCountedSet( + comptime T: type, + comptime Id: type, + comptime RefCountInt: type, + comptime Context: type, +) type { + return struct { + const Self = RefCountedSet(T, Id, RefCountInt, Context); + + pub const base_align = @max( + @alignOf(Context), + @alignOf(Layout), + @alignOf(Item), + @alignOf(Id), + ); + + /// Set item + pub const Item = struct { + /// The value this item represents. + value: T = undefined, + /// Metadata for this item. + meta: Metadata = .{}, + + pub const Metadata = struct { + /// The bucket in the hash table where this item + /// is referenced. + bucket: Id = std.math.maxInt(Id), + + /// The length of the probe sequence between this + /// item's starting bucket and the bucket it's in, + /// used for Robin Hood hashing. + psl: Id = 0, + + /// The reference count for this item. + ref: RefCountInt = 0, + }; + }; + + /// A hash table of item indices + table: Offset(Id), + + /// By keeping track of the max probe sequence length + /// we can bail out early when looking up values that + /// aren't present. + max_psl: Id = 0, + + /// We keep track of how many items have a PSL of any + /// given length, so that we can shrink max_psl when + /// we delete items. + /// + /// A probe sequence of length 32 or more is astronomically + /// unlikely. Roughly a (1/table_cap)^32 -- with any normal + /// table capacity that is so unlikely that it's not worth + /// handling. + psl_stats: [32]Id = [_]Id{0} ** 32, + + /// The backing store of items + items: Offset(Item), + + /// The next index to store an item at. + /// Id 0 is reserved for unused items. + next_id: Id = 1, + + layout: Layout, + + /// An instance of the context structure. + context: Context, + + /// Returns the memory layout for the given base offset and + /// desired capacity. The layout can be used by the caller to + /// determine how much memory to allocate, and the layout must + /// be used to initialize the set so that the set knows all + /// the offsets for the various buffers. + /// + /// The capacity passed for cap will be used for the hash table, + /// which has a load factor of `0.8125` (13/16), so the number of + /// items which can actually be stored in the set will be smaller. + /// + /// The laid out capacity will be at least `cap`, but may be higher, + /// since it is rounded up to the next power of 2 for efficiency. + /// + /// The returned layout `cap` property will be 1 more than the number + /// of items that the set can actually store, since ID 0 is reserved. + pub fn layout(cap: Id) Layout { + // Experimentally, this load factor works quite well. + const load_factor = 0.8125; + + const table_cap: Id = std.math.ceilPowerOfTwoAssert(Id, cap); + const table_mask: Id = (@as(Id, 1) << std.math.log2_int(Id, table_cap)) - 1; + const items_cap: Id = @intFromFloat(load_factor * @as(f64, @floatFromInt(table_cap))); + + const table_start = 0; + const table_end = table_start + table_cap * @sizeOf(Id); + + const items_start = std.mem.alignForward(usize, table_end, @alignOf(Item)); + const items_end = items_start + items_cap * @sizeOf(Item); + + const total_size = items_end; + + return .{ + .cap = items_cap, + .table_cap = table_cap, + .table_mask = table_mask, + .table_start = table_start, + .items_start = items_start, + .total_size = total_size, + }; + } + + pub const Layout = struct { + cap: Id, + table_cap: Id, + table_mask: Id, + table_start: usize, + items_start: usize, + total_size: usize, + }; + + pub fn init(base: OffsetBuf, l: Layout, context: Context) Self { + const table = base.member(Id, l.table_start); + const items = base.member(Item, l.items_start); + + @memset(table.ptr(base)[0..l.table_cap], 0); + @memset(items.ptr(base)[0..l.cap], .{}); + + return .{ + .table = table, + .items = items, + .layout = l, + .context = context, + }; + } + + /// Add an item to the set if not present + /// and increment its reference count. + /// + /// Returns the item's ID. + /// + /// If the set has no more room, then an + /// OutOfMemory error is returned instead. + pub fn add(self: *Self, base: anytype, value: T) error{OutOfMemory}!Id { + const items = self.items.ptr(base); + + // Trim dead items from the end of the list. + while (self.next_id > 1 and items[self.next_id - 1].meta.ref == 0) { + self.next_id -= 1; + + self.deleteItem(base, self.next_id); + } + + // If we still don't have an available ID, we're out of memory. + if (self.next_id >= self.layout.cap) { + return error.OutOfMemory; + } + + const id = self.upsert(base, value, self.next_id); + items[id].meta.ref += 1; + + if (id == self.next_id) { + self.next_id += 1; + } + + return id; + } + + /// Add an item to the set if not present + /// and increment its reference count. + /// If possible, use the provided ID. + /// + /// Returns the item's ID, or null + /// if the provided ID was used. + /// + /// If the set has no more room, then an + /// OutOfMemory error is returned instead. + pub fn addWithId(self: *Self, base: anytype, value: T, id: Id) error{OutOfMemory}!?Id { + const items = self.items.ptr(base); + + if (id < self.next_id) { + if (items[id].meta.ref == 0) { + self.deleteItem(base, id); + + const added_id = self.upsert(base, value, id); + + items[added_id].meta.ref += 1; + + return if (added_id == id) null else added_id; + } else if (self.context.eql(value, items[id].value)) { + items[id].meta.ref += 1; + + return null; + } + } + + return try self.add(base, value); + } + + /// Increment an item's reference count by 1. + /// + /// Asserts that the item's reference count is greater than 0. + pub fn use(self: *const Self, base: anytype, id: Id) void { + assert(id > 0); + assert(id < self.layout.cap); + + const items = self.items.ptr(base); + const item = &items[id]; + + // If `use` is being called on an item with 0 references, then + // either someone forgot to call it before, released too early + // or lied about releasing. In any case something is wrong and + // shouldn't be allowed. + assert(item.meta.ref > 0); + + item.meta.ref += 1; + } + + /// Increment an item's reference count by a specified number. + /// + /// Asserts that the item's reference count is greater than 0. + pub fn useMultiple(self: *const Self, base: anytype, id: Id, n: RefCountInt) void { + assert(id > 0); + assert(id < self.layout.cap); + + const items = self.items.ptr(base); + const item = &items[id]; + + // If `use` is being called on an item with 0 references, then + // either someone forgot to call it before, released too early + // or lied about releasing. In any case something is wrong and + // shouldn't be allowed. + assert(item.meta.ref > 0); + + item.meta.ref += n; + } + + /// Get an item by its ID without incrementing its reference count. + /// + /// Asserts that the item's reference count is greater than 0. + pub fn get(self: *const Self, base: anytype, id: Id) *T { + assert(id > 0); + assert(id < self.layout.cap); + + const items = self.items.ptr(base); + const item = &items[id]; + + assert(item.meta.ref > 0); + + return @ptrCast(&item.value); + } + + /// Releases a reference to an item by its ID. + /// + /// Asserts that the item's reference count is greater than 0. + pub fn release(self: *Self, base: anytype, id: Id) void { + assert(id > 0); + assert(id < self.layout.cap); + + const items = self.items.ptr(base); + const item = &items[id]; + + assert(item.meta.ref > 0); + item.meta.ref -= 1; + } + + /// Release a specified number of references to an item by its ID. + /// + /// Asserts that the item's reference count is at least `n`. + pub fn releaseMultiple(self: *Self, base: anytype, id: Id, n: Id) void { + assert(id > 0); + assert(id < self.layout.cap); + + const items = self.items.ptr(base); + const item = &items[id]; + + assert(item.meta.ref >= n); + item.meta.ref -= n; + } + + /// Get the ref count for an item by its ID. + pub fn refCount(self: *const Self, base: anytype, id: Id) RefCountInt { + assert(id > 0); + assert(id < self.layout.cap); + + const items = self.items.ptr(base); + const item = &items[id]; + return item.meta.ref; + } + + /// Get the current number of non-dead items in the set. + /// + /// NOT DESIGNED TO BE USED OUTSIDE OF TESTING, this is a very slow + /// operation, since it traverses the entire structure to count. + /// + /// Additionally, because this is a testing method, it does extra + /// work to verify the integrity of the structure when called. + pub fn count(self: *const Self, base: anytype) usize { + const table = self.table.ptr(base); + const items = self.items.ptr(base); + + // The number of items accessible through the table. + var tb_ct: usize = 0; + + for (table[0..self.layout.table_cap]) |id| { + if (id != 0) { + const item = items[id]; + if (item.meta.ref > 0) { + tb_ct += 1; + } + } + } + + // The number of items accessible through the backing store. + // The two counts should always match- it shouldn't be possible + // to have untracked items in the backing store. + var it_ct: usize = 0; + + for (items[0..self.layout.cap]) |it| { + if (it.meta.ref > 0) { + it_ct += 1; + } + } + + assert(tb_ct == it_ct); + + return tb_ct; + } + + //================================================// + // The functions below are all internal functions // + // for performing operations on the hash table. // + //================================================// + + /// Delete an item, removing any references from + /// the table, and freeing its ID to be re-used. + fn deleteItem(self: *Self, base: anytype, id: Id) void { + const table = self.table.ptr(base); + const items = self.items.ptr(base); + + const item = items[id]; + + if (item.meta.bucket > self.layout.table_cap) { + return; + } + + if (table[item.meta.bucket] != id) { + return; + } + + if (comptime @hasDecl(Context, "deleted")) { + // Inform the context struct that we're + // deleting the dead item's value for good. + self.context.deleted(item.value); + } + + self.psl_stats[item.meta.psl] -= 1; + table[item.meta.bucket] = 0; + items[id] = .{}; + + var p: Id = item.meta.bucket; + var n: Id = (p + 1) & self.layout.table_mask; + + while (table[n] != 0 and items[table[n]].meta.psl > 0) { + items[table[n]].meta.bucket = p; + self.psl_stats[items[table[n]].meta.psl] -= 1; + items[table[n]].meta.psl -= 1; + self.psl_stats[items[table[n]].meta.psl] += 1; + table[p] = table[n]; + p = n; + n = (n + 1) & self.layout.table_mask; + } + + while (self.max_psl > 0 and self.psl_stats[self.max_psl] == 0) { + self.max_psl -= 1; + } + + table[p] = 0; + } + + /// Find an item in the table and return its ID. + /// If the item does not exist in the table, null is returned. + fn lookup(self: *Self, base: anytype, value: T) ?Id { + const table = self.table.ptr(base); + const items = self.items.ptr(base); + + const hash: u64 = self.context.hash(value); + + for (0..self.max_psl + 1) |i| { + const p = (hash + i) & self.layout.table_mask; + + const id = table[p]; + + // Empty bucket, our item cannot have probed to + // any point after this, meaning it's not present. + if (id == 0) { + return null; + } + + const item = items[id]; + + // An item with a shorter probe sequence length would never + // end up in the middle of another sequence, since it would + // be swapped out if inserted before the new sequence, and + // would not be swapped in if inserted afterwards. + // + // As such, our item cannot be present. + if (item.meta.psl < i) { + return null; + } + + // We don't bother checking dead items. + if (item.meta.ref == 0) { + continue; + } + + // If the item is a part of the same probe sequence, + // we check if it matches the value we're looking for. + if (item.meta.psl == i and + self.context.eql(value, item.value)) + { + return id; + } + } + + return null; + } + + /// Find the provided value in the hash table, or add a new item + /// for it if not present. If a new item is added, `new_id` will + /// be used as the ID. + fn upsert(self: *Self, base: anytype, value: T, new_id: Id) Id { + // If the item already exists, return it. + if (self.lookup(base, value)) |id| { + return id; + } + + const table = self.table.ptr(base); + const items = self.items.ptr(base); + + // The new item that we'll put in to the table. + var new_item: Item = .{ + .value = value, + .meta = .{ + .psl = 0, + .ref = 0, + }, + }; + + const hash: u64 = self.context.hash(value); + + var held_id: Id = new_id; + var held_item: *Item = &new_item; + + var chosen_p: ?Id = null; + var chosen_id: Id = new_id; + + for (0..self.layout.table_cap - 1) |i| { + const p: Id = @intCast((hash + i) & self.layout.table_mask); + const id = table[p]; + + // Empty bucket, put our held item in to it and break. + if (id == 0) { + table[p] = held_id; + held_item.meta.bucket = p; + self.psl_stats[held_item.meta.psl] += 1; + self.max_psl = @max(self.max_psl, held_item.meta.psl); + + break; + } + + const item = &items[id]; + + // If there's a dead item then we resurrect it + // for our value so that we can re-use its ID. + if (item.meta.ref == 0) { + if (comptime @hasDecl(Context, "deleted")) { + // Inform the context struct that we're + // deleting the dead item's value for good. + self.context.deleted(item.value); + } + + chosen_id = id; + + held_item.meta.bucket = p; + self.psl_stats[held_item.meta.psl] += 1; + self.max_psl = @max(self.max_psl, held_item.meta.psl); + + // If we're not still holding our new item then we + // need to make sure that we put the re-used ID in + // the right place, where we previously put new_id. + if (chosen_p) |c| { + table[c] = id; + table[p] = held_id; + } else { + // If we're still holding our new item then we + // don't actually have to do anything, because + // the table already has the correct ID here. + } + + break; + } + + // This item has a lower PSL, swap it out with our held item. + if (item.meta.psl < held_item.meta.psl) { + if (held_id == new_id) { + chosen_p = p; + new_item.meta.bucket = p; + } + + table[p] = held_id; + items[held_id].meta.bucket = p; + self.psl_stats[held_item.meta.psl] += 1; + self.max_psl = @max(self.max_psl, held_item.meta.psl); + + held_id = id; + held_item = item; + self.psl_stats[item.meta.psl] -= 1; + } + + // Advance to the next probe position for our held item. + held_item.meta.psl += 1; + } + + items[chosen_id] = new_item; + return chosen_id; + } + }; +} diff --git a/src/terminal/style.zig b/src/terminal/style.zig index 7e3356bc8..b5bfff6e0 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -6,8 +6,11 @@ const page = @import("page.zig"); const size = @import("size.zig"); const Offset = size.Offset; const OffsetBuf = size.OffsetBuf; -const hash_map = @import("hash_map.zig"); -const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; + +const Wyhash = std.hash.Wyhash; +const autoHash = std.hash.autoHash; + +const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet; /// The unique identifier for a style. This is at most the number of cells /// that can fit into a terminal page. @@ -43,11 +46,34 @@ pub const Style = struct { none: void, palette: u8, rgb: color.RGB, + + /// Formatting to make debug logs easier to read + /// by only including non-default attributes. + pub fn format( + self: Color, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + switch (self) { + .none => { + _ = try writer.write("Color.none"); + }, + .palette => |p| { + _ = try writer.print("Color.palette{{ {} }}", .{ p }); + }, + .rgb => |rgb| { + _ = try writer.print("Color.rgb{{ {}, {}, {} }}", .{ rgb.r, rgb.g, rgb.b }); + } + } + } }; /// True if the style is the default style. pub fn default(self: Style) bool { - return std.meta.eql(self, .{}); + return self.eql(.{}); } /// True if the style is equal to another style. @@ -133,6 +159,83 @@ pub const Style = struct { }; } + /// Formatting to make debug logs easier to read + /// by only including non-default attributes. + pub fn format( + self: Style, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + + const dflt: Style = .{}; + + _ = try writer.write("Style{ "); + + var started = false; + + inline for (std.meta.fields(Style)) |f| { + if (std.mem.eql(u8, f.name, "flags")) { + if (started) { + _ = try writer.write(", "); + } + + _ = try writer.write("flags={ "); + + started = false; + + inline for (std.meta.fields(@TypeOf(self.flags))) |ff| { + const v = @as(ff.type, @field(self.flags, ff.name)); + const d = @as(ff.type, @field(dflt.flags, ff.name)); + if (ff.type == bool) { + if (v) { + if (started) { + _ = try writer.write(", "); + } + _ = try writer.print("{s}", .{ff.name}); + started = true; + } + } else if (!std.meta.eql(v, d)) { + if (started) { + _ = try writer.write(", "); + } + _ = try writer.print( + "{s}={any}", + .{ ff.name, v }, + ); + started = true; + } + } + _ = try writer.write(" }"); + + started = true; + comptime continue; + } + const value = @as(f.type, @field(self, f.name)); + const d_val = @as(f.type, @field(dflt, f.name)); + if (!std.meta.eql(value, d_val)) { + if (started) { + _ = try writer.write(", "); + } + _ = try writer.print( + "{s}={any}", + .{ f.name, value }, + ); + started = true; + } + } + + _ = try writer.write(" }"); + } + + pub fn hash(self: *const Style) u64 { + var hasher = Wyhash.init(0); + autoHash(&hasher, self.*); + return hasher.final(); + } + test { // The size of the struct so we can be aware of changes. const testing = std.testing; @@ -140,170 +243,24 @@ pub const Style = struct { } }; -/// A set of styles. -/// -/// This set is created with some capacity in mind. You can determine -/// the exact memory requirement for a capacity by calling `layout` -/// and checking the total size. -/// -/// When the set exceeds capacity, `error.OutOfMemory` is returned -/// from memory-using methods. The caller is responsible for determining -/// a path forward. -/// -/// The general idea behind this structure is that it is optimized for -/// the scenario common in terminals where there aren't many unique -/// styles, and many cells are usually drawn with a single style before -/// changing styles. -/// -/// Callers should call `upsert` when a new style is set. This will -/// return a stable pointer to metadata. You should use this metadata -/// to keep a ref count of the style usage. When it falls to zero you -/// can remove it. -pub const Set = struct { - pub const base_align = @max(MetadataMap.base_align, IdMap.base_align); - - /// The mapping of a style to associated metadata. This is - /// the map that contains the actual style definitions - /// (in the form of the key). - styles: MetadataMap, - - /// The mapping from ID to style. - id_map: IdMap, - - /// The next ID to use for a style that isn't in the set. - /// When this overflows we'll begin returning an IdOverflow - /// error and the caller must manually compact the style - /// set. - /// - /// Id zero is reserved and always is the default style. The - /// default style isn't present in the map, its dependent on - /// the terminal configuration. - next_id: Id = 1, - - /// Maps a style definition to metadata about that style. - const MetadataMap = AutoOffsetHashMap(Style, Metadata); - - /// Maps the unique style ID to the concrete style definition. - const IdMap = AutoOffsetHashMap(Id, Offset(Style)); - - /// Returns the memory layout for the given base offset and - /// desired capacity. The layout can be used by the caller to - /// determine how much memory to allocate, and the layout must - /// be used to initialize the set so that the set knows all - /// the offsets for the various buffers. - pub fn layout(cap: usize) Layout { - const md_layout = MetadataMap.layout(@intCast(cap)); - const md_start = 0; - const md_end = md_start + md_layout.total_size; - - const id_layout = IdMap.layout(@intCast(cap)); - const id_start = std.mem.alignForward(usize, md_end, IdMap.base_align); - const id_end = id_start + id_layout.total_size; - - const total_size = id_end; - - return .{ - .md_start = md_start, - .md_layout = md_layout, - .id_start = id_start, - .id_layout = id_layout, - .total_size = total_size, - }; +pub const StyleSetContext = struct { + pub fn hash(self: *StyleSetContext, style: Style) u64 { + _ = self; + return style.hash(); } - pub const Layout = struct { - md_start: usize, - md_layout: MetadataMap.Layout, - id_start: usize, - id_layout: IdMap.Layout, - total_size: usize, - }; - - pub fn init(base: OffsetBuf, l: Layout) Set { - const styles_buf = base.add(l.md_start); - const id_buf = base.add(l.id_start); - return .{ - .styles = MetadataMap.init(styles_buf, l.md_layout), - .id_map = IdMap.init(id_buf, l.id_layout), - }; - } - - /// Possible errors for upsert. - pub const UpsertError = error{ - /// No more space in the backing buffer. Remove styles or - /// grow and reinitialize. - OutOfMemory, - - /// No more available IDs. Perform a garbage collection - /// operation to compact ID space. - Overflow, - }; - - /// Upsert a style into the set and return a pointer to the metadata - /// for that style. The pointer is valid for the lifetime of the set - /// so long as the style is not removed. - /// - /// The ref count for new styles is initialized to zero and - /// for existing styles remains unmodified. - pub fn upsert(self: *Set, base: anytype, style: Style) UpsertError!*Metadata { - // If we already have the style in the map, this is fast. - var map = self.styles.map(base); - const gop = try map.getOrPut(style); - if (gop.found_existing) return gop.value_ptr; - - // New style, we need to setup all the metadata. First thing, - // let's get the ID we'll assign, because if we're out of space - // we need to fail early. - errdefer map.removeByPtr(gop.key_ptr); - const id = self.next_id; - self.next_id = try std.math.add(Id, self.next_id, 1); - errdefer self.next_id -= 1; - gop.value_ptr.* = .{ .id = id }; - - // Setup our ID mapping - var id_map = self.id_map.map(base); - const id_gop = try id_map.getOrPut(id); - errdefer id_map.removeByPtr(id_gop.key_ptr); - assert(!id_gop.found_existing); - id_gop.value_ptr.* = size.getOffset(Style, base, gop.key_ptr); - return gop.value_ptr; - } - - /// Lookup a style by its unique identifier. - pub fn lookupId(self: *const Set, base: anytype, id: Id) ?*Style { - const id_map = self.id_map.map(base); - const offset = id_map.get(id) orelse return null; - return @ptrCast(offset.ptr(base)); - } - - /// Remove a style by its id. - pub fn remove(self: *Set, base: anytype, id: Id) void { - // Lookup by ID, if it doesn't exist then we return. We use - // getEntry so that we can make removal faster later by using - // the entry's key pointer. - var id_map = self.id_map.map(base); - const id_entry = id_map.getEntry(id) orelse return; - - var style_map = self.styles.map(base); - const style_ptr: *Style = @ptrCast(id_entry.value_ptr.ptr(base)); - - id_map.removeByPtr(id_entry.key_ptr); - style_map.removeByPtr(style_ptr); - } - - /// Return the number of styles currently in the set. - pub fn count(self: *const Set, base: anytype) usize { - return self.id_map.map(base).count(); + pub fn eql(self: *StyleSetContext, a: Style, b: Style) bool { + _ = self; + return a.eql(b); } }; -/// Metadata about a style. This is used to track the reference count -/// and the unique identifier for a style. The unique identifier is used -/// to track the style in the full style map. -pub const Metadata = struct { - ref: size.CellCountInt = 0, - id: Id = 0, -}; +pub const Set = RefCountedSet( + Style, + Id, + size.CellCountInt, + StyleSetContext, +); test "Set basic usage" { const testing = std.testing; @@ -313,29 +270,49 @@ test "Set basic usage" { defer alloc.free(buf); const style: Style = .{ .flags = .{ .bold = true } }; + const style2: Style = .{ .flags = .{ .italic = true } }; - var set = Set.init(OffsetBuf.init(buf), layout); + var set = Set.init(OffsetBuf.init(buf), layout, StyleSetContext{}); - // Upsert - const meta = try set.upsert(buf, style); - try testing.expect(meta.id > 0); + // Add style + const id = try set.add(buf, style); + try testing.expect(id > 0); - // Second upsert should return the same metadata. + // Second add should return the same metadata. { - const meta2 = try set.upsert(buf, style); - try testing.expectEqual(meta.id, meta2.id); + const id2 = try set.add(buf, style); + try testing.expectEqual(id, id2); } // Look it up { - const v = set.lookupId(buf, meta.id).?; + const v = set.get(buf, id); try testing.expect(v.flags.bold); - const v2 = set.lookupId(buf, meta.id).?; + const v2 = set.get(buf, id); try testing.expectEqual(v, v2); } - // Removal - set.remove(buf, meta.id); - try testing.expect(set.lookupId(buf, meta.id) == null); + // Add a second style + const id2 = try set.add(buf, style2); + + // Look it up + { + const v = set.get(buf, id2); + try testing.expect(v.flags.italic); + } + + // Ref count + try testing.expect(set.refCount(buf, id) == 2); + try testing.expect(set.refCount(buf, id2) == 1); + + // Release + set.release(buf, id); + try testing.expect(set.refCount(buf, id) == 1); + set.release(buf, id2); + try testing.expect(set.refCount(buf, id2) == 0); + + // We added the first one twice, so + set.release(buf, id); + try testing.expect(set.refCount(buf, id) == 0); }