← board

NilPy object reclamation — dict/list/instance/bound-method lifetime

The user's resolution of [[decide-uforth-exec-leak-strategy]] (2026-07-22): uforth stays untouched — it is a test case; improvement suggestions go to the uforth repo itself, not here. The compiler must not leak on Python code: "let leaks be due to application bugs, not compiler errors." So this is the work item, and it must land either way.

Current state

STALE — this section describes 2026-07-22, BEFORE the work. Slices 1-4 landed that same night; see "Progress" below for what is actually true. Kept because the ranked remainder is written against it, but read the Progress and NEXT sections first: a reader who stops here concludes NilPy objects are not refcounted at all, which has been wrong since 2026-07-23 (noted 2026-08-08, after exactly that misreading produced a wrong claim in a filed ticket).

The concrete driver

uforth's exec_python_inline allocates per PYTHON-word call: env TPyDict {vm, push, pop, fpush, fpop} + ns TPyDict + 5 bound-method pairs + wrapper string → ~4-5 KB/call orphaned. 20k-iter doloop = 553 MB peak vs CPython's 24 MB (make bench-uforth tracks it). Per-op RSS probes and the reduction ladder live in the umbrella ticket.

Shape (not decided here — implementation's call, escalate if forked)

Refcount class instances like AnsiString handles (retain on bind, release on unbind/scope-exit, recursive release of variant-slot payloads/fields), OR scope-tied arena for instances that provably don't escape. Watch: cycles (vm ↔ words) — CPython solves with GC; a refcount-only scheme leaks cycles, which for uforth's env-per-call pattern is still ~all of the 553 MB, so refcounting is a legitimate first rung. The user's caution from FPC history (threaded ansistring leaks): threading × memory management is hard — the threadsafe heap lock discipline (heap-size-class allocator, PXXStr* lock protocol) applies to every new release path.

Gate: uforth doloop RSS bounded and near-CPython; test-nilpy green; self-host byte-identical; the vstr/vbox probes in the umbrella stay flat.

Design pass done (fable-abcnp, 2026-07-22)

Full design in devdocs/dev/nilpy-object-reclamation.md — written while the leak investigation was warm. Summary: rc in the existing heap-block header word ([-16], the AnsiString protocol), ownership rules mirroring the string ones (call results owned, lvalues retained — the layer-5 discrimination), recursive per-type finalizers via a VMT slot, cycles explicitly out of scope (FPC-grade contract), everything behind the NilPy-user gate (isNilPy AND CurrentUnitIdx<0 — the pyeval landmine). Five-slice ladder, slices 1-3 inert/additive, slice 4 (scope-exit release of NilPy tyClass locals) is the one that drains uforth's env-per-call and carries the risk. Verification set in the doc. Pick up at slice 1.

Progress (fable-a-n, night 2026-07-22/23)

Slices 1-4 LANDED (commits 0b39d0ea..HEAD): primitives + PXXObjAlloc construction route (whole NilPy compilation, uniform headers, PXX_OBJ_MAGIC population tag at [inst-8]); variant-slot ARC for VT_OBJECT/VT_BOUNDMETHOD (x86-64 emitters via reg-preserving obj blobs, portable helpers for cross); recursive finalizers (PXXObjFinalizeHook -> pylib PyObjFinalize; RAW magic for bound pairs which own +1 on recv; class layout kind 5 = variant fields); binding ARC + scope-exit release (owned = construction/call results via return-retain; borrows retain; field-store ARC pulled forward; PXXObjPlausible heap-envelope guard).

doloop RSS 595 -> 369 MB (as of the valgrind-profile night). Remaining tail to the <40 MB target:

Night follow-ups (2026-07-23, fable-a-n)

Landed since the slice-4 note: variant hidden-dest temp pre-call clear; mid-body tyClass watermark zero-init (hidden temps join ARC); refcounted pyeval closure objects (RAW2 magic + registry recycle stack, VT_PYCLOSURE=9 in all ARC arms); construction-in-arg spill to owning temp (pathIdx>=1). doloop 595 -> 413 MB; plain container/bound-method churn probes flat.

Night 2026-08-30/31 (frankS) — two landed fixes, and the NEXT list re-measured

Every item below was measured, not read. Probe shape: the same program at two loop counts, /usr/bin/time max RSS, CPython as the oracle for the printed value. Scratch probes, not tests, except where a test is named.

LANDED 1 — 1205cf286: every managed field of every NilPy class instance leaked, unless the program happened to build a container. PXXObjFinalizeHook is what PXXObjRelease calls at rc=0 to release an instance's children. All nine install sites are pylib/pyeval CONSTRUCTORS (pylist_new, pydict_new, pybound_new, bytes, the iterators), so a program that builds user-class instances and never a container ran with the hook nil: the block was freed and not one field released. 200k constructions of a class with one 2000-byte string field: 410 MB peak. Adding an unrelated dummy = [1] to the same program made it flat at 980 kB — the leak was gated on a feature the program did not use. Fixed from pylib's own initialization, the way pyeval's section already fixed the identical shape for PyIterCallHook. Byte-identical code size. Guard: test_nilpy_class_field_no_container_no_leak.npy (RSS ceiling 20 MB; positive control run — it fails pre-fix by 20x), and the .npy carries a warning that a container added to it anywhere disarms it.

LANDED 2 — ca8153b6c: a construction stored into a VARIANT was retained twice, on all three backends with an inline object arm. A NilPy construction is lowered through a conduit local (inst := PXXObjAlloc(size), VMT stamp, ctor, then LOAD_SYM), so the variant store — decided in CODEGEN — never saw the IR_CALL. The AST-level arms ask IRNodeYieldsOwnedRef and see the AN_CALL, which is why a tyClass local and a tyClass FIELD were already flat and only the variant slot leaked. Three backends, three different wrong answers: x86-64 listed the three call kinds, aarch64 listed none, i386 retained unconditionally (so i386 leaked an ordinary v = f() too). One predicate, IRNodeOwnsManagedObj, forwarded in compiler.pas beside its string twin; it can answer because the conduit is flagged where it is minted (SymIsCtorResultTemp). 400k o.w = Inner(i) into a variant field: 22932 kB -> 1044 kB. Guard: test_nilpy_object_in_variant_slot_survives_churn.npy, which is also the "shared instance in two locals" canary this ticket listed as unwritten — it reads keep.v and an a is b pair AFTER 50k constructions have churned through the same conduit, so it fails in the other direction too.

The NEXT list below is STALE in three places. Re-measured:

NEXT (ranked):

  1. Literal-chain ownership: list/dict literals lower as Self-returning chains (Create.append(a).append(b)); the chain result IS the receiver, so receiver-position constructions cannot be ARC-spilled (test_nilpy_forin regression showed why). Fix in pyparser: hoist __py_t := Create + append statements (PyHoistHead exists), yield the temp IDENT as the expression. Closes arg-position literal leaks (exec(src, {...}) 15 MB / 20k probe; genexp-join wrapper build ~200 B/iter).
  2. bug-n-pyeval-per-exec-leaks (see that ticket): ~24B/exec site-2 string + caller-side 64B with wrapper build.
  3. Class-typed FIELDS in finalizer (kind for tyClass + release) — field refs leak on instance death.
  4. aarch64 EmitVariantClearA64/RetainA64 object arms + non-x86 scope-exit tyClass release arm (leak-only asymmetry today).
  5. d = None (RHS non-class) rebind leaks the old binding's ref.

RESOLVED 2026-08-31 (frankS) — the driver is met and exceeded, measured

make bench-uforth at 041204c7a, host plexus, uforth 07ffdb1:

wall max RSS
microbench-doloop, cpython 13068.7 ms 24.8 MB
microbench-doloop, pxx 19328.2 ms 16.7 MB

The gate was "uforth doloop RSS bounded and near-CPython" against a starting point of 595 MB and a stated target of <40 MB. It is 16.7 MB, which is below CPython's own 24.8 MB, and flat across prelim and core too. 595 -> 413 -> 16.7.

Wall time is 0.68x CPython on that row and is NOT this ticket — it belongs to bug-o-uforth-blocktest-runs-slower-under-pxx-than-under-cpython. Saying so explicitly because a reader who wants this ticket to stay open will reach for that number.

What was verified, named exactly: make compiler/pascal26 byte-identical self-host at every step; gate.sh quick GREEN three times; nine named .npy canaries against the CPython oracle; the two new tests; an object-into-variant Pascal probe RUN on i386, aarch64, arm32 and riscv32; bench-uforth. NOT verified here: test-nilpy in full, which is full-tier and Track T's sweep, not this lane's gate (CLAUDE.md's per-fix loop supersedes this ticket's own Gate: line).

The residual tail, all of it, now has somewhere to live — none of it is carried by this ticket any more:

Log