mem: List(T) mutations gain optional alloc: Allocator = context.allocator

The chess panel-text regression (text vanished after the first move on
macOS) had a single root cause: GlyphCache's entries List, hash table,
and shaped_buf grew through `context.allocator` — which during render
is the per-frame arena. On the next arena reset the backing died, and
subsequent glyph lookups read garbage / wrote into freshly-allocated
view-tree memory.

Fix is shaped as the user proposed: `List(T)`'s mutations take an
optional trailing `alloc: Allocator = context.allocator` argument. No
allocator stored on the container, no init ceremony, every existing
`list.append(item)` callsite keeps working unchanged. Long-lived
owners now write `list.append(item, self.parent_allocator)` and the
arena-leak bug becomes impossible to write accidentally.

Default-arg substitution previously only fired for identifier callees
(`expandCallDefaults` at lower.zig:7978). Extended to the generic
struct-method dispatch path (`list.append(...)` lands here) via a new
`appendDefaultArgs` helper that lowers fd.params[i].default_expr in
the caller's scope and appends to the lowered args slice.

Long-lived owners updated to capture `parent_allocator: Allocator` at
init and use it for every internal growth:

- GlyphCache (the chess bug) — entries, shaped_buf, hash_keys,
  hash_vals, atlas bitmap.
- DockInteraction — drops the existing `push Context` workaround in
  `ensure_capacity` for the explicit-arg form.
- StateStore — entries list + per-entry data buffer.
- Gles3Gpu, MetalGPU — shaders, buffers, textures (atlas-grow during
  render would otherwise leak resources into the frame arena).

Also kept: an operator-precedence fix in pipeline.sx
(`(self.frame_index & 1) == 0` instead of
`self.frame_index & 1 == 0`, which parses as
`self.frame_index & (1 == 0)` = always 0). That was a stealth
single-arena-only bug that masked the GlyphCache one for a long time.

Docs:
- specs.md §11 documents `param: T = expr` default parameter values.
  The parser already supported it — formalised in the spec now.
- current/CHECKPOINT-MEM.md logs the change.
- CLAUDE.md REJECTED PATTERNS gains a "Long-lived containers growing
  through context.allocator" section with the `parent_allocator`
  capture template and the list of existing examples to mirror.

155/155 example tests pass — zero-diff against snapshots since every
existing callsite still resolves to `context.allocator`.
This commit is contained in:
agra
2026-05-25 14:41:17 +03:00
parent b263704664
commit 72593db953
11 changed files with 258 additions and 63 deletions

View File

@@ -134,22 +134,19 @@ DockInteraction :: struct {
self.header_pressed = List(bool).{};
}
// BLOCKED on issue-0009: should use push instead of manual save/restore
ensure_capacity :: (self: *DockInteraction, count: s64) {
if self.child_count >= count { return; }
push Context.{ allocator = self.parent_allocator, data = context.data } {
while self.child_count < count {
self.natural_sizes.append(Size.zero());
self.alignment_overrides.append(ALIGN_CENTER);
self.has_alignment_override.append(false);
self.is_floating.append(false);
self.is_fill.append(false);
self.floating_positions.append(Point.zero());
self.child_bounds.append(Frame.zero());
self.anim_sizes.append(Animated(Size).make(Size.zero()));
self.header_pressed.append(false);
self.child_count += 1;
}
while self.child_count < count {
self.natural_sizes.append(Size.zero(), self.parent_allocator);
self.alignment_overrides.append(ALIGN_CENTER, self.parent_allocator);
self.has_alignment_override.append(false, self.parent_allocator);
self.is_floating.append(false, self.parent_allocator);
self.is_fill.append(false, self.parent_allocator);
self.floating_positions.append(Point.zero(), self.parent_allocator);
self.child_bounds.append(Frame.zero(), self.parent_allocator);
self.anim_sizes.append(Animated(Size).make(Size.zero()), self.parent_allocator);
self.header_pressed.append(false, self.parent_allocator);
self.child_count += 1;
}
}

View File

@@ -179,6 +179,11 @@ GlyphCache :: struct {
last_shape_len: s64;
last_shape_size_q: u16;
// Allocator that owns every dynamically-grown buffer on this cache —
// entries list, hash table, shaped_buf. Captured at init time so growth
// never accidentally lands in a transient per-frame arena.
parent_allocator: Allocator;
// GPU protocol backend. When set, atlas creation + dirty uploads route
// through `gpu` instead of raw GL.
gpu: ?GPU = null;
@@ -190,6 +195,7 @@ GlyphCache :: struct {
// Zero out the entire struct first (parent may be uninitialized with = ---)
memset(self, 0, size_of(GlyphCache));
self.gpu = saved_gpu;
self.parent_allocator = context.allocator;
// Load font file
file_size : s32 = 0;
@@ -204,7 +210,7 @@ GlyphCache :: struct {
self.font_data_size = file_size;
// Init stbtt_fontinfo
self.font_info = context.allocator.alloc(FONTINFO_SIZE);
self.font_info = self.parent_allocator.alloc(FONTINFO_SIZE);
memset(self.font_info, 0, FONTINFO_SIZE);
stbtt_InitFont(self.font_info, font_data, 0);
@@ -235,7 +241,7 @@ GlyphCache :: struct {
self.atlas_width = GLYPH_ATLAS_W;
self.atlas_height = GLYPH_ATLAS_H;
bitmap_size : s64 = xx self.atlas_width * xx self.atlas_height;
self.bitmap = xx context.allocator.alloc(bitmap_size);
self.bitmap = xx self.parent_allocator.alloc(bitmap_size);
memset(self.bitmap, 0, bitmap_size);
// Shelf packer init
@@ -251,10 +257,10 @@ GlyphCache :: struct {
// Init hash table (256 slots)
self.hash_cap = 256;
hash_bytes : s64 = self.hash_cap * 4; // u32 per slot
self.hash_keys = xx context.allocator.alloc(hash_bytes);
self.hash_keys = xx self.parent_allocator.alloc(hash_bytes);
memset(self.hash_keys, 0, hash_bytes);
val_bytes : s64 = self.hash_cap * 8; // s64 per slot (s32 would suffice but alignment)
self.hash_vals = xx context.allocator.alloc(val_bytes);
self.hash_vals = xx self.parent_allocator.alloc(val_bytes);
// Create the atlas texture. In GPU-protocol mode we create empty and
// let the first `flush()` push the (zero-initialized) bitmap via
@@ -328,7 +334,7 @@ GlyphCache :: struct {
advance = advance
}
};
self.entries.append(entry);
self.entries.append(entry, self.parent_allocator);
self.hash_insert(key, self.entries.len - 1);
return @self.entries.items[self.entries.len - 1].glyph;
}
@@ -371,7 +377,7 @@ GlyphCache :: struct {
advance = advance
}
};
self.entries.append(entry);
self.entries.append(entry, self.parent_allocator);
self.hash_insert(key, self.entries.len - 1);
return @self.entries.items[self.entries.len - 1].glyph;
}
@@ -399,10 +405,10 @@ GlyphCache :: struct {
self.hash_cap = old_cap * 2;
hash_bytes : s64 = self.hash_cap * 4;
self.hash_keys = xx context.allocator.alloc(hash_bytes);
self.hash_keys = xx self.parent_allocator.alloc(hash_bytes);
memset(self.hash_keys, 0, hash_bytes);
val_bytes : s64 = self.hash_cap * 8;
self.hash_vals = xx context.allocator.alloc(val_bytes);
self.hash_vals = xx self.parent_allocator.alloc(val_bytes);
// Rehash
mask := self.hash_cap - 1;
@@ -420,8 +426,8 @@ GlyphCache :: struct {
i += 1;
}
context.allocator.dealloc(old_keys);
context.allocator.dealloc(old_vals);
self.parent_allocator.dealloc(old_keys);
self.parent_allocator.dealloc(old_vals);
}
// Upload dirty atlas to GPU. On the Metal path, defer the upload to
@@ -482,7 +488,7 @@ GlyphCache :: struct {
new_w := self.atlas_width * 2;
new_h := self.atlas_height * 2;
new_size : s64 = xx new_w * xx new_h;
new_bitmap : [*]u8 = xx context.allocator.alloc(new_size);
new_bitmap : [*]u8 = xx self.parent_allocator.alloc(new_size);
memset(new_bitmap, 0, new_size);
// Copy old rows into new bitmap
@@ -494,7 +500,7 @@ GlyphCache :: struct {
y += 1;
}
context.allocator.dealloc(self.bitmap);
self.parent_allocator.dealloc(self.bitmap);
self.bitmap = new_bitmap;
self.atlas_width = new_w;
self.atlas_height = new_h;
@@ -599,7 +605,7 @@ GlyphCache :: struct {
x = total,
y = 0.0,
advance = adv
});
}, self.parent_allocator);
total += adv;
i += 1;
}
@@ -632,7 +638,7 @@ GlyphCache :: struct {
x = gx,
y = gy,
advance = adv
});
}, self.parent_allocator);
total += adv;
}
}

View File

@@ -132,7 +132,7 @@ UIPipeline :: struct {
}
tick_with_body :: (self: *UIPipeline) {
build_arena : *Arena = if self.frame_index & 1 == 0 then self.arena_a else self.arena_b;
build_arena : *Arena = if (self.frame_index & 1) == 0 then self.arena_a else self.arena_b;
build_arena.reset();
// Reset render_tree nodes (backing is stale after arena reset)

View File

@@ -24,10 +24,12 @@ StateEntry :: struct {
StateStore :: struct {
entries: List(StateEntry);
current_generation: s64;
parent_allocator: Allocator;
init :: (self: *StateStore) {
self.entries = List(StateEntry).{};
self.current_generation = 0;
self.parent_allocator = context.allocator;
}
get_or_create :: (self: *StateStore, id: s64, $T: Type, default: T) -> State(T) {
@@ -42,14 +44,14 @@ StateStore :: struct {
}
// Create new entry
data : [*]u8 = xx context.allocator.alloc(size_of(T));
data : [*]u8 = xx self.parent_allocator.alloc(size_of(T));
memcpy(data, @default, size_of(T));
self.entries.append(.{
id = id,
data = data,
size = size_of(T),
generation = self.current_generation
});
}, self.parent_allocator);
State(T).{ ptr = xx data };
}