← all posts

`g == h` could free `h` halfway through. Now the operand holds its own reference.

Earlier today object values got the native ownership model: a module global owns its value, and a borrowed read of it costs nothing. One window was left open. A borrowed operand was pinned only when a later sibling could run code – but the operation that consumes it runs Python code too.

g == h calls g.__eq__. If that re-executes the module body (a nested import of the same compiled module shares its global slots), h is rebound and its old value freed. CPython then calls the reflected h.__eq__ – on freed memory. Same story for d[k] (the key’s __hash__ runs before the dict is probed), k in d, d[k] = v, g + h and g + box.o. Under -X dev + PYTHONMALLOC=debug each of those segfaulted. 💥

The fix 🔧

CPython’s C API borrows its arguments and relies on the caller’s reference – the one on the value stack. So an operation that runs CPython code now pins each object operand that some other activation can drop: a module global, or a read through an attribute or list element. The pin is a Py_INCREF after the read and a Py_DECREF after the operation, and it reuses the existing pin machinery, so every consumer already releases it exactly once.

Not pinned, on purpose:

  • Frame locals. Only their own frame stores to them; a callee can’t rebind the caller’s locals, and a nested exec gets its own activation.
  • Call arguments and callees. The shim already packs each argument into a new reference, and a borrowed callee is retained by pycc_ext_obj_call_borrowed. A method call’s receiver is pinned only across the lookup – the bound method holds it for the call.

Numbers 📏

  • New oracle test: 16 operations on rebound globals, in a module body and in a function, match CPython line for line under the debug allocator; 5 more calls leave live objects and refcounts unchanged. Before the fix the same test segfaults.
  • The five native benchmarks: identical machine code in every function.