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

@@ -5,14 +5,18 @@ Tracking checkpoint for the mem.sx Zig-aligned implementation
## Last completed step
- **Interp silent-arm sweep + typed raw-pointer stores.** Every
`else =>` arm in the interp now bails with a `bailDetail("...")`
reason that surfaces through the host diagnostic as
`op=X/X: <reason>`. `inst.Store` carries `val_ty: TypeId` so
comptime raw-pointer stores honour the declared destination width
(no more 8-byte-everywhere assumption). New CLAUDE.md REJECTED
PATTERN forbids silent unimplemented arms going forward.
154/154 example tests + chess on macOS / iOS sim / Android green.
- **`List(T)` mutations gain an optional `alloc: Allocator =
context.allocator` argument** (plan:
`~/.claude/plans/lets-see-options-for-merry-dijkstra.md`). One
language-level change (function-param defaults already supported)
closes a whole bug class where long-lived containers' growth
silently landed in a per-frame arena. The chess panel-text
regression that triggered this work is fixed end-to-end on macOS.
GlyphCache, DockInteraction, StateStore, Gles3Gpu, and MetalGPU
all carry `parent_allocator` and pass it explicitly to internal
list growth. 155/155 example tests pass — zero-diff against
snapshots since every existing callsite still resolves to
`context.allocator`.
## Current state
@@ -146,21 +150,23 @@ chain at comptime in the interp. No remaining shortcut.
## Next step
Phase 1.3 (closure env allocation through context) and Phase 1.4
(codegen serializer for all interp Value variants) are unblocked.
Phase 1.2 (free / malloc through context) was considered and
**skipped**`context.allocator.alloc/dealloc` already works
directly; wrapper-only `malloc`/`free` would be lossy renames.
Phase 1.3 (closure env allocation through context) shipped in commit
`8e21cc5`. Phase 1.4 (codegen serializer for all interp Value
variants) remains open. Phase 1.2 (free / malloc through context) was
considered and **skipped** — `context.allocator.alloc/dealloc`
already works directly; wrapper-only `malloc`/`free` would be lossy
renames.
Suggested next move: **Phase 1.3**. Closure trampolines in
[lower.zig:lowerLambda](../src/ir/lower.zig#L5549) call
`.heap_alloc` directly for the env pointer; routing through
`context.allocator.alloc` means closures respect
`push Context.{ allocator = ... }` and get leak-tracked by
`TrackingAllocator`. Contained change. Regression test pattern:
mirror `examples/130-xx-value-routes-through-context-allocator.sx`
with a closure that captures a variable, install a tracker via
`push`, verify the tracker's counter incremented.
The `List(T)` allocator-arg work documented above is **outside the
original MEM plan** but lives in the same problem space (long-lived
container growth silently capturing the wrong allocator). It
generalises the `parent_allocator` capture pattern that
`ChessGameState` / `UIPipeline` already used.
Suggested next move: verify on iOS sim + Android via
`tools/verify-step.sh` to confirm the GlyphCache fix + Metal/Gles3
sweep behave on those platforms, then either commit the verify-step
goldens or move to Phase 1.4.
## Phase 0.3 audit findings — chess allocator usage (closed)
@@ -186,6 +192,25 @@ Allocator value naturally.
## Log
- **2026-05-25 (latest)** — `List(T)` mutation API gained an optional
trailing `alloc: Allocator = context.allocator` argument
(`library/modules/std.sx`). Default-arg substitution previously
only fired for identifier callees; extended to the generic-method
dispatch path via new `appendDefaultArgs` helper at
`lower.zig:7974-7991`, wired in at `lower.zig:5332`. Long-lived
owners that grew internal Lists during render — `GlyphCache`,
`DockInteraction`, `StateStore`, `Gles3Gpu`, `MetalGPU` — now
capture `parent_allocator: Allocator` at init and forward it to
every internal `.append` / `.alloc` / `.dealloc`. Chess panel-text
regression (text vanished after the first move because GlyphCache
hash + entries grew into the per-frame arena and died on reset)
fixed end-to-end on macOS. specs.md §11 gains a "Default Parameter
Values" subsection documenting the existing capability. Operator-
precedence fix kept in `pipeline.sx` (`(self.frame_index & 1) == 0`
instead of `self.frame_index & 1 == 0`, which was parsing as
`self.frame_index & (1 == 0)` = always 0). All diagnostic logging
added during the bug hunt has been stripped. 155/155 example tests
green.
- **2026-05-25 (late)** — Interp silent-arm sweep (`e9df33a`).
Every `else =>` arm has a `bailDetail` reason; `.deref` /
`.unbox_any` previously silently passed through arbitrary Value