escape (fix): the arena took ludic.ui's pooled nodes - a generic's call and an unknown callee now keep what they are handed

memory_final's gate crashed in all 13 scenarios at the first frame of play, R3D_ARENA_CHECK=1 reading
0xDD in ludic.ui's nd_take: a node the pool keeps had come from the frame's scratch. Two holes:

- a call to a generic (ui_kept(list, n)) names the generic, and the analysis knows only its instances
  (ui_kept$UiNode), so the callee looked unknown - and an unknown callee was taken to keep nothing.
  A generic's call now reaches every instance, and an unknown callee keeps everything it is handed,
  but for a short list of intrinsics known to keep nothing; view() shares its list's storage.
- a push's growth into a parameter's list was LOCAL whenever the list was not seen kept, though a
  parameter may be a state's list. A site is LOCAL now only when its class is neither ESC nor HEAP.

examples/lang/arena_pool.ludic is the shape (a pool keeping records across frames through a generic
push): built with --arena it prints '7 3498' poisoned on every reset and with the arena off, in
ludic-dev test. On the valley every ludic.ui pool site is kept; 5480 sites local, 6431 kept.

Also, from ECS: a record's field defaults are stored into it when it is made, a global's initializer
is kept, and a component's own functions are frame roots (they run while its page is open).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-28 17:13:05 +03:00
parent a6364199da
commit c8a588b2de
6 changed files with 62210 additions and 59807 deletions

View file

@ -224,6 +224,16 @@ function es_val(e: Node) -> int {
var i = 0
while i < len(e.a.kids) { es_store(es_ref(e.a.kids[i].a), s); i += 1 }
}
# a field's default is made with the record and held by it (`facts: Queue<F> = queue_new(...)`)
let lay = find_comp(e.s)
if lay != null and lay.kids != null {
var j = 0
while j < len(lay.kids) {
let fd = lay.kids[j]
if fd.a != null and not es_overridden(e, fd.s) { es_store(es_ref(fd.a), s) }
j += 1
}
}
return s
}
if k == E_LIST {
@ -242,6 +252,39 @@ function es_val(e: Node) -> int {
return g_es_heap
}
function es_overridden(e: Node, field: pointer) -> bool {
if e.a == null or e.a.kids == null { return false }
var i = 0
while i < len(e.a.kids) { if (e.a.kids[i].s == field) { return true }; i += 1 }
return false
}
# the function records a call to `nm` reaches: nm itself, or every instance of a generic nm
function es_fns_for(nm: pointer) -> []int {
let out = new []int
let f = es_fn(nm)
if f >= 0 {
push(out, f)
return out
}
let pre = `{nm}$`
var i = 0
while i < len(g_es_fnode) {
let d = g_es_fnode[i]
if d.s != null and str_starts(d.s, pre) { push(out, i) }
i += 1
}
return out
}
function es_keeps_nothing(nm: pointer) -> bool {
if (nm == "quit") or (nm == "exit") or (nm == "panic") or (nm == "assert") or (nm == "expect") or (nm == "expect_eq") or (nm == "expect_near") { return true }
if (nm == "min") or (nm == "max") or (nm == "abs") or (nm == "clamp") or (nm == "sqrt") or (nm == "floor") or (nm == "ceil") or (nm == "round") { return true }
if (nm == "int") or (nm == "float") or (nm == "long") or (nm == "double") or (nm == "fixed") or (nm == "as_int") or (nm == "as_fixed") { return true }
if (nm == "float_bits") or (nm == "float_from_bits") or (nm == "sin") or (nm == "cos") or (nm == "atan2") or (nm == "file_write") or (nm == "file_close") { return true }
return false
}
function es_args_escape(e: Node) -> void {
var i = 0
while i < len(e.kids) { es_escape(es_val(e.kids[i])); i += 1 }
@ -293,26 +336,47 @@ function es_call(e: Node) -> int {
return -1
}
if (nm == "len") { es_args_walk(e); return -1 }
let f = es_fn(nm)
if f >= 0 {
push(g_es_calls_f, g_es_cur)
push(g_es_calls_t, f)
let d = g_es_fnode[f]
if (nm == "view") and len(e.kids) >= 1 { # a view shares its list's storage
let base = es_val(e.kids[0])
var vi = 1
while vi < len(e.kids) { es_val(e.kids[vi]); vi += 1 }
return base
}
# a generic's call names the generic; its instances (ui_kept$UiNode) are what is analysed, and
# the call reaches every one of them
let fs = es_fns_for(nm)
if len(fs) > 0 {
let r = es_new(0)
var fi = 0
while fi < len(fs) {
let f = fs[fi]
push(g_es_calls_f, g_es_cur)
push(g_es_calls_t, f)
es_flow(g_es_fret[f], r)
fi += 1
}
var i = 0
while i < len(e.kids) {
let v = es_ref(e.kids[i])
if i < len(d.kids) and d.kids[i].kind == N_PARAM {
if not es_prim(d.kids[i].ty) { es_flow(v, g_es_fparam[f] + i) }
} else { es_escape(v) }
var fj = 0
while fj < len(fs) {
let d = g_es_fnode[fs[fj]]
if i < len(d.kids) and d.kids[i].kind == N_PARAM {
if not es_prim(d.kids[i].ty) { es_flow(v, g_es_fparam[fs[fj]] + i) }
} else { es_escape(v) }
fj += 1
}
i += 1
}
let r = es_new(0)
es_flow(g_es_fret[f], r)
return r
}
# an intrinsic or a runtime builtin of the emitter's own: it keeps nothing it is handed, and
# what it hands back is its own
es_args_walk(e)
# an intrinsic known to keep nothing it is handed; anything else unknown keeps it all - the
# arena is only as safe as this default
if es_keeps_nothing(nm) {
es_args_walk(e)
return g_es_heap
}
es_args_escape(e)
return g_es_heap
}
if es_is_mem_frame(e) {
@ -541,6 +605,18 @@ function escape_analyse() -> void {
f += 1
}
g_es_cur = -1
# a global's initializer - a state's instance, a module's table - is made to be kept
var gi = 0
while gi < len(prog) {
let gd = prog[gi]
if gd.kind == N_VAR and gd.a != null {
g_es_ln = new []pointer
g_es_lc = new []int
g_es_lt = new []pointer
es_escape(es_ref(gd.a))
}
gi += 1
}
# an @On body keeps nothing it makes unless it stores it; it has no function record of its own
var li = 0
while li < len(g_onlisten) {
@ -557,7 +633,9 @@ function escape_analyse() -> void {
let c = g_es_site_cls[s]
let fi = g_es_site_fn[s]
let spans = fi < 0 or g_es_fspans[fi]
if c >= 0 and (g_es_flag[c] & ES_ESC) == 0 and not spans {
# LOCAL only when it can neither be kept (ESC) nor share storage with what is (HEAP): a push's
# growth into a parameter's list is the list's own buffer, and the list may be a state's
if c >= 0 and (g_es_flag[c] & (ES_ESC | ES_HEAP)) == 0 and not spans {
n.uns = ES_SCRATCH
g_es_local_n += 1
} else {

View file

@ -151,6 +151,7 @@ function deps_frame(f: pointer) -> void {
let d = prog[i]
if d.kind == N_SYS and d.s != null and fr_phase_frame(d.ty) { fr_mark_root(d.s) }
if d.kind == N_FN and d.s != null and str_starts(d.s, "ludic_reduce__") { fr_mark_root(d.s) }
if d.kind == N_FN and d.s != null and d.cm >= 0 { fr_mark_root(d.s) } # a component's own: it runs while its page is open
fr_find_roots(d)
i += 1
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff