Two runtimes, two memory models, and no shared view between them. This is what has been settled, what is measured, and what is still open.
CPython counts references; a Kotlin wrapper takes one on construction and gives it back when it closes.
Python creates the object refcount 1
a Kotlin wrapper takes it refcount 2
Python drops its reference refcount 1, still alive
the Kotlin wrapper closes refcount 0, freed
RefCountTest checks this in both directions, because both failure modes are silent: releasing
one too few leaks, and releasing one too many frees an object still in use — that crash lands
somewhere unrelated, which is how the interpreter corruption during the lifetime work first
appeared, as a segfault inside Py_Finalize far from its cause.
A wrapper's release is GC-driven: no close() is needed, and an explicit release is idempotent and
runs exactly once (SPEC M-1). Python3.initialize() parks its thread state with PyEval_SaveThread(),
so a cleaner thread's PyGILState_Ensure attaches instead of blocking on the GIL the initialising
thread would otherwise keep. The release path is verified on desktop (java.lang.ref.Cleaner), iOS
(createCleaner) and wasm, and Android below API 33 takes the PhantomReference fallback (SPEC M-2).
History: ROADMAP §1 and §4.
On a free-threaded build, a decrement from a thread that does not own the object is queued to the
owner and only drained at an eval-loop checkpoint, which a pure C-API embedder never reaches; the
library therefore takes a periodic checkpoint itself (Python3.drainPendingReleases,
autoDrainInterval, on by default only for free-threaded builds). See ROADMAP §9,
docs/investigations/gc-scheduling-investigation.md and threading-and-abi.md.
There is no counterpart to Py_INCREF for a JVM or Kotlin/Native object, so something on the
Kotlin side has to act as a GC root:
private val liveHandles = HashMap<Long, Any>() // handle -> Kotlin object
fun register(obj: Any): Long { val h = next++; liveHandles[h] = obj; return h }
fun release(h: Long) { liveHandles.remove(h) }
Python holds only the integer handle; the map holds the object. This is what JNI's
NewGlobalRef does, made explicit.
The sketch above has no answer to that, and neither did the implementation. HandleTable is
generational precisely so that a double release is safe, and its class doc named the counterpart
exactly — "the proxy's tp_dealloc calling release is the entire lifetime contract" — but
PythonProxySource rendered a proxy class whose __init__ took a handle and which had no path
back at all. Every Kotlin object Python ever constructed stayed rooted for the life of the process.
It was found by a measurement that was not looking for it. GeneratedProxyCostTest's constructor
row builds ten thousand proxies and discards every one; the desktop run ended with 78 002 live
handles, and the constructor figure was unusable because every row in the report had been timed
against a table that grew by tens of thousands of entries while it ran.
Two separate defects wore that one number, and it is worth keeping them apart:
| who owns the root | what was wrong | |
|---|---|---|
a proxy instance (Counter(10)) |
the Python object | the library: no __del__ was rendered. Fixed; ProxyHandleLifetimeTest pins it, including for a proxy inside a reference cycle |
a bare handle from _pm_invoke on a CONSTRUCTOR entry |
the caller | nothing was wrong with the library. An integer has nothing to hang a finaliser off, so the caller must call _pm_release; the measurement harness was not doing it |
The leak was identical on desktop and on the iOS simulator — 64 handles registered, 64 still
live after gc.collect(), on both — which is what one would expect of a defect in generated Python
that commonMain renders. The earlier impression that "iOS was stable" was about the measurement
and not about the table: the iOS boundary costs about 3.5 µs against desktop's 0.5 µs, so the same
drift was proportionally invisible there.
__del__ was chosen over weakref.finalize on price — the alternatives and their measured costs
are tabulated in PythonProxySource's KDoc. The pre-PEP-442 objection to __del__ (an object with
one, inside a cycle, was never finalised at all) has not applied since CPython 3.4, and the cycle
case is asserted rather than assumed.
Note what this does not solve: the table still holds strongly, so the K1 -> P -> K2 -> K1
case below is untouched. What changed is only that "Python has finished with it" now reaches Kotlin
at all, which is the precondition for anything else.
A note for anyone reading older discussion: Panama has no equivalent of NewGlobalRef, and it
does not need one. Panama is an API for native memory, not for JVM object lifetime — a
MemorySegment holding an address does not keep a Kotlin object alive. The GC root is an
ordinary Kotlin data structure, not anything Panama provides. Looking for the answer inside
Panama was looking at the wrong layer.
Not a boundary problem — an inherent limit of reference counting, in any language:
a = []; b = []
a.append(b); b.append(a)
del a, b # each still holds one, count never reaches 0
This is why CPython carries a cyclic collector on top of refcounting. So "refcounting cannot free cycles" is not a defeat; it was never refcounting's job.
CPython's algorithm is simple, and it turns on one requirement:
- each tracked object's
tp_traverseenumerates thePyObjects it holds - references discovered that way are subtracted from the refcount
- if the remainder is zero, the object is kept alive only from inside the cycle — collectable
tp_cleardrops those references and breaks the cycle
Everything depends on step 1 being able to see through whatever holds the reference.
P -> PK (Python proxy) -> [handle] -> K (Kotlin) -> PyObject wrapper -> P
Without help this never collects, and each collector has a defensible reason:
- CPython's GC traverses
PyObjects.PKholds an opaque integer, so traversal stops there andPlooks externally referenced. - The JVM's GC collects cycles fine, but
Kis held by a strong entry in the handle map, which is a GC root.
Each side sees a live root it cannot reason about, and concludes the other is responsible.
Make the Kotlin object's Python references visible to tp_traverse. PK's traverse reaches
through the handle and enumerates the PyObject-typed fields of K; CPython then sees the
cycle as internal and can collect it.
This is cheap here specifically because the exposure table is generated at build time. KSP
already walks every exposed class to build the function table, and at that point it knows which
fields are PyObject-typed. A traverse entry is one more generated function:
// generated
fun traverse_MyClass(obj: MyClass, visit: (Long) -> Unit) {
obj.someField?.let { visit(it.pointer.toRawValue()) }
obj.otherField?.let { visit(it.pointer.toRawValue()) }
}
No reflection — which is the whole reason the table exists. A reflection-based binding would find this expensive; that is why mature ones (JPype and similar) document "do not create cycles" instead of solving it. We are not in that position.
tp_traverse runs during collection, and CPython requires it not to allocate or run arbitrary
code, while ours has to call into the JVM. The generated traverse is therefore restricted to field
reads and visit calls. ProxyTypeFactory's tp_traverse (every target) calls only the generated
traverse function and visit; CycleCollectionTest / WasmCycleCollectionTest /
RefHolderCycleCollectionTest exercise it through a real gc.collect() (SPEC M-3).
tp_clear needs no Kotlin field mutation. The first design assumed breaking the cycle meant
nulling K's PyObject fields (so they would have to be var, or cleared through a generated
accessor). That is not what was built. ProxyTypeFactory.tp_clear (desktopMain/.../ProxyTypeFactory.kt,
mirrored on every other target) never touches K's fields; it calls HandleTable.release() on PK's
handle and nothing else, deliberately not close()-ing the held PyObjects: CPython's own cyclic
collector, not this callback, reclaims the other side, and close() here would double-free. Dropping
the strong root PK -> [handle] -> K is enough to let the JVM collect K once CPython's collector
also breaks P's incoming edge. tp_dealloc is overridden separately for the non-cyclic route; the
two are safe to run one after the other (HandleTable.release is idempotent).
Cycles that close on the Kotlin side. K1 -> P -> K2 -> K1 needs the JVM's collector to see
through P, and there is no traverse hook on that side. Worse, the handle map holds K2
strongly, so it is a root regardless.
Still open. The handle table holds strongly (HandleTable.kt notes it "needs to hold weakly" for
this case; ROADMAP §14b records it as untouched). The likely answer is to make the map hold weakly,
with the strong reference supplied by the Python proxy for as long as it lives — so "Python stopped
using it" becomes "the JVM may collect it". That inverts who owns the strength and has not been
worked through.
An earlier discussion concluded that "cycles are handled by each language's GC" on the strength of the four-step refcount sequence at the top of this document. That sequence is correct, and it is implemented and tested — but it describes the non-cyclic case. Step 4, "the Kotlin side releases", is exactly what a cycle prevents from ever happening. The claim does not follow, and the same discussion walked it back a few messages later.
PyContext chooses how far a Python value is converted toward Kotlin, per
ConversionStrategy; withContext swaps and restores the strategy even on throw, and all five
variants dispatch. PyValue's lazy path is filled in too: the stub that used to end in
cachedNativeValue!! — an NPE on any genuine cache miss — is gone. No !! on that field remains
anywhere in the library sources; the only occurrence of the expression left in the tree is a
comment in ConversionTest recording that it used to be there.
What makes the cache safe is a per-type rule, because a cached native value and the
PyObject it came from have different lifetimes. Caching a value that copied itself out of
CPython is fine; caching one that still points into Python-owned memory is a use-after-free
waiting for the address to be reused. The rule below is derived from what each conversion
actually returns, not from what the type looks like.
PyProxy.toKotlinOrNull converts through pyObjectToNative, whose dispatch compares the source's
exact type object against PyTypeChecks' cached builtin types — PyLong_Check semantics, not
isinstance. So the table is by exact type; a subclass falls through to the last row.
| Python type | Conversion | Kotlin result | Points into Python memory? | Cached? |
|---|---|---|---|---|
None |
PyNone.isNone, a pointer comparison |
null |
no | re-derived; null cannot be told apart from "not computed", and the comparison costs no FFI call |
bool |
PyLong_AsLongLong(...) != 0 |
Boolean |
no — a scalar copy | yes |
int |
PyLong_AsLongLong |
Long |
no — a scalar copy (64-bit range only; wider ints do not round-trip) | yes |
float |
PyFloat_AsDouble |
Double |
no — a scalar copy | yes |
str |
PyUnicode_AsUTF8 |
String |
the C call returns a const char* owned by CPython, but that pointer never leaves the binding: EmbedAPI's expect returns String?, and each platform decodes at the boundary (toKString() on Kotlin/Native, Panama.readUtf8String on desktop, Wasm.readUtf8String on wasmJs). The String is a copy. |
yes — the String, never the pointer |
list, tuple |
toNativeList(), recursive |
List<Any?> |
no — every element is itself one of the rows above | yes, as a snapshot |
dict |
toNativeMap(), recursive over a snapshot of the entries |
Map<Any?, Any?> |
no | yes, as a snapshot |
set, frozenset |
toNativeSet(), recursive over PySequence_Tuple |
Set<Any?> |
no | yes, as a snapshot |
| anything else inside a container | pyObjectToNative's else: str(obj) |
String |
no — a copy, but lossy and one-way | yes |
bytes, bytearray |
bytes.hex() across the boundary, decoded to bytes on this side |
ByteArray |
yes — a copy, and bytearray a snapshot |
yes |
memoryview |
none — refused | null / PyTypeError |
the buffer protocol hands out a pointer into the object, and a copy would still not carry format, shape and strides | never |
user-defined classes, subclasses of builtins, complex, ... |
none — refused | null / PyTypeError |
— | never |
The container rows are the answer to "what happens when the elements are themselves PyObjects":
they are not. pyObjectToNative recurses, so a converted container bottoms out in Long,
Double, Boolean, String and null, and holds no PyObject, no NativePointer and no
borrowed C pointer. PyValueLazyConversionTest walks a converted nested container and fails on
anything else, so this is checked rather than asserted in prose.
Being a copy is also what makes a container stale rather than dangling: mutating the Python
container afterwards does not update it. PyProxy.invalidateNativeCache() is the way out, and the
staleness and the recovery are both pinned by tests.
ConversionStrategy |
Hands back | Cacheable |
|---|---|---|
RAW |
a bare NativePointer — no wrapper, no incref, nothing that releases it |
never. It is an address, not a reference. PyValue's constructor rejects one as initialNativeValue, because storing it would fail only once the address had been reused, far from the cause |
UNMANAGED |
the PyObject unchanged, under Python's own refcounting |
no native value exists to cache |
TYPED / DEFAULT |
a PyValue over a wrapper it owns a reference to |
per the type table above |
NATIVE |
the fully-converted value, eagerly | nothing holds it; there is no cache in this path |
isIndependentOfPythonMemory in PyProxy.kt is the rule as code: true for null, the Kotlin
scalars, String, ByteArray and containers built recursively out of those; false for a
NativePointer (an unowned address) and for a PyObject (a reference, but not a native value),
and false for anything unrecognised. Every store to cachedNativeValue on the conversion path
passes through it, and a value it rejects is still returned to the caller — it is simply
re-converted next time instead of kept. Today every branch the walk can reach passes; the guard
exists so a branch added later cannot acquire a cache without someone deciding that it should.
The Python side has the opposite rule: cachedPyObjectValue must hold an owned reference,
never a borrowed one, because the proxy can outlive whatever handed the object over.
PyContext.proxyConvert did not honour it. typedWrap returns a fresh wrapper — built
borrowed = true, i.e. with its own incref — for the nine builtins it knows, and returns the
caller's wrapper unchanged for everything else. On that second path the PyValue held no
reference of its own, so a caller closing its wrapper (which callers do; hasNativeCounterpart
does it to its own scratch wrapper) left the PyValue pointing at a released object. Measured as
a missing +1 on sys.getrefcount — 3 where 4 was required — by
PyValueLazyConversionTest.pyContextGivesEveryPyValueItsOwnReference, and fixed by taking an
independent reference on the fallback path. Both branches now hand the PyValue a wrapper nobody
else holds.
-
bytesandbytearrayconvert throughhex(), not through the buffer. This entry used to say they had no conversion at all and that the bindings could not get there. The second half is still true:PyBytes_AsString/PyByteArray_AsStringare declared to returnString?, so each platform decodes the buffer as a NUL-terminated UTF-8 string and truncates binary data at the first zero byte. They cannot represent arbitrary bytes.What changed is that the buffer is not the only route.
bytes.hex()is exact, pure ASCII, and costs three FFI crossings whatever the length, where walking the sequence costs one per byte. The price is four passes over the payload where a direct copy would make one, which makes this the largest per-byte cost in the conversion layer — the case for adding a realPyBytes_AsStringAndSizebinding later. That binding is not free either: a non-primitive value would have to cross four platform boundaries againstandroidMain's primitives-only rule. -
Subclasses of builtins are refused because the dispatch is by exact type. That is deliberate and consistent with
pyObjectToNative, but it meansclass MyInt(int)gets no conversion even thoughPyLong_AsLongLongwould work on it. -
The cache is not synchronised. Two threads converting the same proxy can both convert; the result is idempotent, so this costs work rather than correctness.
-
A self-referential container recurses.
pyObjectToNativehas no visited set, so alistthat contains itself has no defined behaviour here. Not measured; not reached by any current caller.