Skip to content

Binder: closing a PyObject argument inside an upcall crashes later (SIGSEGV in Py_XDECREF on the Cleaner thread) #98

Closed

Description

@b-re-w

What

A KSP-bound Kotlin function receives a Python object as PyObject? and calls close() on it inside the call. The JVM later dies with SIGSEGV in Py_XDECREF on the Cleaner-0 thread, during PyObjectKt.topLevelDecRefAction → CleanupAction.run. In other words, one reference is released twice.

fun tpTouch(token: PyObject?): Long { token?.close(); return 1L }

UpcallTrampoline.kt:71 documents arguments as borrowed, with the wrapper built borrowed = true so that it takes its own reference. On that basis close() should hand back exactly the reference the wrapper took.

Evidence (ksp-fixtures/app, TypedPython #45 test, desktop, 3.14.7 GIL)

  • With close(): Gradle Test Executor exits 134. hs_err: SIGSEGV at libpython3.14.dylib Py_XDECREF; the Java frames are topLevelDecRefAction ← PyObject$1.invoke ← CleanupAction.run ← PhantomCleanableRef.performCleanup (Cleaner-0).
  • Without close(): no crash. After 10,000 calls the token's sys.getrefcount has gone from 10 to 17,350 until the Kotlin collector runs. Release is deferred, as designed.

Where to look

PyAutoCloseable.close() → PlatformCleaner.close(): Java's Cleanable.clean() is idempotent, so close() plus a later Cleaner run should not release twice by itself. The second release must come from somewhere else. Candidates:

  • the trampoline or argument conversion also dropping the wrapper's reference;
  • a wrapper built borrowed = false on this path;
  • two wrappers over one pointer.

AGENTS §16's reference-convention warning describes exactly this failure.

Done when

A ksp-fixtures test that closes a PyObject argument inside an upcall, 10,000 times, runs clean and leaves the refcount unchanged once the Kotlin collector has run. Nothing is released twice.

Activity

  1. b-re-w commented on Oct 3, 2026

    @b-re-w
    MemberAuthor

    Update: close() is not needed to trigger it. The #45 test was changed to never close the argument and to force collection instead: System.gc() and System.runFinalization() in a loop after 10,000 upcalls, until the refcounts settle. The test JVM then died the same way: SIGSEGV in Py_XDECREF on Cleaner-0, in PyObjectKt.topLevelDecRefAction ← CleanupAction.run (ksp-fixtures/app/hs_err_pid84367.log in the TypedPython #45 worktree).

    Without forced GC, the token's refcount reads 10 → 17,350 after the loop, so the wrappers did take references. When the Cleaner later releases them, at least one reference is released one time too many. So the double release is on the ordinary Cleaner path for wrappers created during upcalls, not specific to close(). Any long-running app that passes Python objects to Kotlin would eventually hit it once GC runs.

    Which wrapper is released twice is not yet established: the token argument, the TpSample proxy objects built by the constructor, or the callables. A bisect would run the loop with only one of the three kinds of calls at a time.

  2. b-re-w commented on Oct 3, 2026

    @b-re-w
    MemberAuthor

    Landed in develop via #118.

  3. added a commit that references this issue on Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions