← board

Measured 2026-10-03 (frankuser)

How it was found

Measuring lekkerzeilen's leak slope headless (--shot, Xvfb, --region rijn), the compiled demo died in about half its runs, with a different exception each time. A lock bisection in a scratch copy of the demo (never in its own tree) narrowed it to the loader thread's tile.load(): loading synchronously gave 8/8 identical runs, and a lock around load() alone gave 0 crashes in 8. Extracted: a loader thread iterating for ... in db.execute(..) while the main thread allocates failed 12 runs of 12 (SIGSEGV, IndexError, "too many values to unpack (expected 2, got 6)" -- the main thread unpacking one of the loader's rows). fetchall() and a fetchone() loop were clean, and so was a single thread under -dPXX_HEAP_DEBUG at module level. Inside a def, single-threaded, HEAP_DEBUG reported the double release outright, so threads were only the amplifier.

The mechanism

PyParseForIn wrapped a user iterable whose value is OWNED (PyCallYieldsOwnedObj) in pyiter_of_userobj_owned, which drops the caller's reference once the cursor holds its own. For a construction that is the only release. For an owned CALL result it is the second one: IRLowerCallArg spills an object-returning proc call to an owning temp even in argument position 0, and that temp releases at scope exit. Module level was spared because the loop's value was held differently there.

The loop now takes the _owned entry only when the value (read through a fold, whose value is its last element) is a construction, the one owned value that spill leaves alone in position 0.

Verified

Not this ticket

The demo's leak slope: see bug-n-the-demo-leaks-16-mb-per-two-minutes-on-a-real-world-and-it-is-not-in-the-render-path for the measurement taken the same day.