← board

How should the NilPy managed finalize re-enter the heap lock?

Track U. The measurement, the two arms and the numbers are on [[bug-a-nilpy-under-threadsafe-still-leaks-every-class-field-and-it-cannot-ride-on-the-pascal-fix]], section "2026-09-02 (frankA) — the experiment RAN". This ticket is only the fork.

The fork

The unification is built and measured. It is right about everything except one program, and that program is not exotic:

class Inner:
    def __init__(self, s):
        self.s = s

class Outer:
    def __init__(self):
        self.f = Inner("hello")

o = Outer()
o = 0

PXXClassFinalizeManaged runs under the codegen heap lock, reaches kind 6, releases the Inner, and PyObjFinalize -> PXXClassFinalize calls PXXClassFinalizeManaged again — the wrapper acquires a non-reentrant spinlock the same thread already holds. One instance, one thread, no contention.

Option (a) — make the lock reentrant

The obvious fix, and it is exactly the half of [[feature-a-reentrant-heap-lock-and-per-thread-arenas]] that ticket closed WITHOUT doing: "the reentrancy half stays parked by the owner and was never touched." That parking is why this is a decide and not a task. It is also the only arm that fixes the sibling leaks under the same lock without further thought — the dynamic-array element walk ([[bug-a-threadsafe-builds-leak-every-variant-and-interface-element-of-a-dynamic-array]], where ManagedElemKindLocked degrades kinds 4 and 6 to 0 for the same reason) and the record COM-interface fields named at the bottom of the bug ticket.

Cost: every acquire grows an owner check, on the allocator's hot path — the path the magazine work was measured against. A depth counter local to the one wrapper is NOT a substitute: the nested PXXClassFinalize runs its kind-4 pass, whose FreeMem takes the lock at a DIFFERENT codegen site, so a wrapper-local counter still deadlocks there.

Option (b) — defer the nested release

The kind-6 arm of PXXRecordRelease pushes the object pointer onto a per-thread pending list instead of calling PXXObjRelease. The codegen wrapper drains the list immediately after EmitReleaseHeapLock, which is the one site that already knows the lock is being dropped and is where the acquire is emitted today.

Cost: a finalizer now runs after the outer walk instead of during it. For container teardown that is invisible; for anything user-visible it is an observable ordering change, and NilPy is upward-compatible with CPython, where the ordering is defined. It also needs a bound on the list, or a deep object graph turns into an unbounded per-thread allocation at exactly the moment the program is trying to free memory.

It touches no lock primitive, which is the part the owner parked, and that is the whole reason it is worth putting beside (a) rather than assuming (a).

Recommendation

(b), scoped to the kind-6 arm only, unless the owner wants to unpark reentrancy anyway for the two sibling leaks. (a) is the better end state and strictly more expensive to get wrong; (b) is available to a session today. The ordering objection against (b) is real but narrow, and narrower than it reads: the only NilPy value whose release re-enters is a user class instance, and NilPy has no __del__ at allgrep -rn __del__ compiler/ lib/ test/ is empty, 2026-09-02. So there is no user-visible finalizer today whose ordering (b) could change, and its cost is a cost against a feature nobody has built. If __del__ is ever added it lands on top of whichever arm is chosen here, which is the argument for (a) rather than against (b).

2026-09-06 — DECIDED BY THE OWNER: (a), THE REENTRANT LOCK

Asked directly, with both arms and their costs in front of him. He had already said he leaned (a) against the ticket's recommendation of (b), and asked for advice rather than for confirmation. The advice given was (a), on four grounds, and he took it: "good. decided then."

1. THE UNPARK TRIGGER IS HIS OWN AND IT HAS FIRED. feature-a-reentrant-heap-lock-and-per-thread-arenas records, 2026-08-21:

"i'm good and if we ever encounter a real world project that has an issue, we will look at it again."

Unpark trigger: a real-world project hitting it. A deadlock, or a new managed member kind whose release cannot be hoisted out of the lock. Not "it would be tidier".

A deadlock is one of the two named triggers. Twelve lines, one instance, one thread, exit 212. This is not an override of a parked decision; it is the condition he specified, arriving.

2. THE HOT-PATH COST OBJECTION WAS FORMED BEFORE THE THING THAT WEAKENS IT. The stated cost is "every acquire grows an owner check, on the allocator's hot path — the path the magazine work was measured against." The magazine work then landed. GetMem/FreeMem on x86-64 now have a lock-free per-thread magazine fast path emitted at the call sites; the hot path largely does not take the lock at all, so an owner check is paid on the SLOW path. The objection has never been measured, and it inherited a premise that has since moved. It still stands on i386/aarch64/arm32, which keep the global lock.

3. (b)'s FAILURE MODE IS WORSE THAN THE LEAK IT FIXES. The pending list needs a bound, or a deep object graph becomes unbounded per-thread allocation at the moment the program is trying to free memory. A leak degrades; an OOM during teardown does not.

4. THE STRUCTURAL ONE, WHICH WEIGHED HEAVIEST. decide-interface-members-in-aggregates-lock-strategy already took (b) — the unlocked-pass discipline — and recorded the standing objection against it: every future release site must remember to run outside the lock, where a reentrant lock would delete the hazard class. This is the second site that forgot. Taking (b) again is choosing to meet it a third time.

What this changes beyond this ticket

(a) closes three of the six open leaks under one lock, not one — this row, the dynamic-array element walk (ManagedElemKindLocked degrades kinds 4 and 6 to 0 for the same reentrancy reason), and the record COM-interface fields. The ticket's recommendation of (b) was written when only one was in view.

It also unblocks [[feature-n-nilpy-has-no-del-and-its-absence-is-load-bearing-in-an-open-fork]]: under (a) a user finalizer runs in place and may allocate, so the CPython ordering question (b) would have raised does not arise.

The one measurement that still attaches

The owner check's cost on the acquire path, min-of-N interleaved A/B, POST-magazine. Not as a gate — the decision is made — but because the number this arm was argued against has never been taken, and the non-magazine targets (i386, aarch64, arm32) are where it could still be real. Take it and record it; do not re-open the fork on it without talking to the owner.