← board

A borrowed object returned out of a function is over-released

Seven-line repro, wrong answer (not a crash), CPython is the oracle:

store = {}
def ensure(k: int) -> list:
    if k not in store:
        store[k] = [1, 2, 3]
    return store[k]
def m():
    t = ensure(1)
    t[1] = 5
m()
print(len(store[1]), store[1])     # CPython: 3 [1, 5, 3]      pxx: 0 []

Everything is correct until m RETURNS. Printing inside m shows the right length before and after the mutation; the container's element dies when the local t goes out of scope. bytearray behaves the same, as does a list held in a list rather than a dict. Two shapes are NOT affected: the same code at module scope (no frame to exit), and an ensure with NO return annotation — unannotated makes t a variant (tk=22) rather than a class reference (tk=6), which takes a different path entirely.

Root cause

compiler/ir.inc's ARC model (feature-nilpy-object-reclamation slices 4/5) has one predicate for ownership, IRNodeYieldsOwnedRef:

IRNodeYieldsOwnedRef := (n >= 0) and
  ((ASTKind[n] = AN_CALL) or (ASTKind[n] = AN_VIRTUAL_CALL) or ...);

"A construction is rc=1 out of PXXObjAlloc and a call result carries the callee's return-retain." Both arms of the model agree: the RETURN arm retains a borrowed value on the way out, and the ASSIGN arm therefore does not retain a call result.

The hole is that a Python index is an AN_CALL. store[k] lowers to a call into the pylib dict accessor, so the return arm asks IRNodeYieldsOwnedRef(store[k]), gets True, and skips the retain — but a pylib accessor hands back a BORROW, not a +1. The caller then assumes owned, releases at scope exit, and the container's reference is what dies.

Measured with PXXDBG=a.ast:ensure / a.ir:ensure: the return node's left child is kind=8 (AN_CALL) into the dict getter, and the emitted IR for the return path is call <getter> -> store_sym tmp -> store_sym $pyresult with no PXXObjRetain anywhere.

Why this is not a one-line fix

The predicate cannot answer the question from the AST node alone: bytearray(8) is also an AN_CALL and IS owned. What is missing is per-callee ownership — "does this proc return a +1 reference?" — which is true for NilPy user functions (they emit the return-retain) and for pylib constructors, and false for every pylib accessor. TProc has no unit/ownership field and must not grow one (see the TSymbol-field landmine); a parallel array is the shape.

Belongs with [[feature-nilpy-object-reclamation]] — same model, same file, and the classification is the piece that ticket has not reached yet. Track A rather than N: the change is in the shared ir.inc, not in the NilPy frontend files.

Found by

uforth's ANS blocktest.fth, via [[bug-nilpy-uforth-ans-word-set-suite-4-of-13-open]]. flush_blocks does self._ensure_block(blk)[:] = self.memory[addr:addr + BLOCK_SIZE] against an _ensure_block(self, blk) -> bytearray — so the block store's buffer is freed the moment the flush helper returns, and the next block access segfaults. The word set dies at 1 BLOCK DROP UPDATE + FLUSH, five TESTING groups in.

Gate

The repro above matches CPython, make test-nilpy green, self-host byte-identical, and blocktest.fth byte-identical to CPython running the same uforth.py.

Log