Skip to content

Segfault freeing a deeply nested list after creating a JVM on the main thread (3.14 regression) #159639

Open

Description

@ctriley

Bug report

Bug description:

After a JVM is created in-process on the main thread (as JPype and r5py do), freeing a deeply nested list segfaults on 3.14 and 3.15. The same script runs cleanly on 3.13.

This reproducer uses only ctypes and a JDK, with no third-party Python packages:

"""Free a deeply nested list after starting a JVM in-process (JNI via ctypes)."""

import ctypes
import pathlib
import subprocess
import sys

java_home = subprocess.run(
    ["java", "-XshowSettings:properties", "-version"], capture_output=True, text=True
).stderr.split("java.home = ")[1].split()[0]
libjvm = ctypes.CDLL(str(pathlib.Path(java_home, "lib", "server", "libjvm.so")))


class JavaVMOption(ctypes.Structure):
    _fields_ = [("optionString", ctypes.c_char_p), ("extraInfo", ctypes.c_void_p)]


class JavaVMInitArgs(ctypes.Structure):
    _fields_ = [
        ("version", ctypes.c_int),
        ("nOptions", ctypes.c_int),
        ("options", ctypes.POINTER(JavaVMOption)),
        ("ignoreUnrecognized", ctypes.c_ubyte),
    ]


flags = [arg.encode() for arg in sys.argv[1:]]
options = (JavaVMOption * max(len(flags), 1))(*[JavaVMOption(f, None) for f in flags])
args = JavaVMInitArgs(0x00010008, len(flags), options, 0)  # JNI_VERSION_1_8
vm, env = ctypes.c_void_p(), ctypes.c_void_p()
assert libjvm.JNI_CreateJavaVM(ctypes.byref(vm), ctypes.byref(env), ctypes.byref(args)) == 0
print(sys.version.split()[0], "JVM started with", sys.argv[1:] or "default options", flush=True)

value = None
for _ in range(100_000):
    value = [value]
print("built", flush=True)
del value
print("freed", flush=True)

Results on Linux x86_64 (Ubuntu 24.04, glibc 2.39, OpenJDK 21.0.12, ulimit -s = 8192). The Python builds are the python-build-standalone interpreters installed by uv:

Python JVM options Result
3.13.14 default built, freed, exit 0
3.14.6 default built, then SIGSEGV (exit 139)
3.15.0b2 default built, then SIGSEGV (exit 139)
3.14.6 / 3.15.0b2 -XX:+UnlockExperimentalVMOptions -XX:+DisablePrimordialThreadGuardPages built, freed, exit 0

Other observations:

  • On 3.14.6, the crash happens when freeing the list. Nesting depths of 1,000 to 10,000 free cleanly; 30,000 and above crash.
  • Without the JVM, 3.14 frees the 100,000-deep list without a problem.
  • With the JVM loaded, unbounded Python recursion (def f(): return f()) still raises RecursionError normally. Only the deallocation path crashes.
  • No Python traceback or HotSpot hs_err crash log is produced. That fits the main thread's stack running into the JVM's guard pages.

My guess is that this comes from 3.14 checking C stack depth against the thread's actual stack bounds, which the trashcan deallocation path relies on. HotSpot adds guard pages to the primordial thread's stack, and CPython may not account for them. DisablePrimordialThreadGuardPages stops HotSpot from adding those guard pages, and the crash goes away. I haven't confirmed the mechanism in CPython's source.

We hit this in a pytest suite where some tests import r5py, which starts a JVM through JPype, and other tests build deeply nested values to check that the code under test doesn't recurse. The pytest-xdist workers died with "node down: Not properly terminated". As a workaround we now set JAVA_TOOL_OPTIONS="-XX:+UnlockExperimentalVMOptions -XX:+DisablePrimordialThreadGuardPages".

CPython versions tested on:

3.13, 3.14, 3.15

Operating systems tested on:

Linux

Activity

  1. added
    interpreter-core(Objects, Python, Grammar, and Parser dirs)
    type-crashA hard crash of the interpreter, possibly with a core dump
    on Oct 17, 2026
  2. NekoAsakura commented on Oct 17, 2026

    @NekoAsakura
    Member

    CPython 3.14 measures the thread's C stack once, when the thread starts, and from then on every recursion check just compares the stack pointer against that saved range (gh-91079). Since gh-124715 the trashcan in _Py_Dealloc() uses the same saved range instead of counting depth. If the usable stack gets smaller afterwards, CPython keeps recursing past the real end. Lowering RLIMIT_STACK is enough to show it:

    import resource, sys
    soft, hard = resource.getrlimit(resource.RLIMIT_STACK)
    resource.setrlimit(resource.RLIMIT_STACK, (1024 * 1024, hard))
    value = None
    for _ in range(100_000):
        value = [value]
    print("built", flush=True)
    del value
    print("freed")

    3.13 frees the list. 3.14+ segfaults (3.14.7, 3.15.0rc2; Linux x86_64, glibc 2.39):

    $ gdb -q -batch -ex run -ex bt --args python3.14 repro.py
    built
    
    Program received signal SIGSEGV, Segmentation fault.
    0x0000000001a0659c in list_dealloc ()
    #0  0x0000000001a0659c in list_dealloc ()
    #1  0x0000000001a0659f in list_dealloc ()
    #2  0x0000000001a0659f in list_dealloc ()
    ...
    #12973 0x0000000001c67e65 in pymain_run_file ()
    #12974 0x0000000001b454cf in Py_RunMain ()
    #12975 0x0000000001b45048 in pymain_main.llvm ()
    #12976 0x0000000001b44e46 in main ()
    

    Not just deallocation: repr(), ==, pickle.dumps() and json.dumps() on the same list segfault as well. Without the setrlimit line they raise RecursionError.

    cc: @markshannon @vstinner @encukou

  3. self-assigned this
    on Oct 19, 2026
  4. corona10 commented on Oct 19, 2026

    @corona10
    Member

    It's reproducible on linux build but not in macOS.

  5. encukou commented on Oct 19, 2026

    @encukou
    Member

    HotSpot adds guard pages to the primordial thread's stack, and CPython may not account for them. DisablePrimordialThreadGuardPages stops HotSpot from adding those guard pages, and the crash goes away. I haven't confirmed the mechanism in CPython's source.

    Could you try calling PyUnstable_ThreadState_SetStackProtection with a size that excludes the guard pages (if that size is known)?

  6. vstinner commented on Oct 19, 2026

    @vstinner
    Member

    I can reproduce @NekoAsakura's reproducer (#159639 (comment)) crash on the 3.14 branch, but I think that it's different from the original bug report. @NekoAsakura's reproducer calls setrlimit(RLIMIT_STACK) which is another use case.

    When I reproduce the crash:

    $ cat /proc/266241/maps|grep stack
    7fffffeff000-7ffffffff000 rw-p 00000000 00:00 0                          [stack]
    

    The stack size is exactly 1024 * 1024 bytes (0x7ffffffff000 - 0x7fffffeff000 is 1048576 bytes).

    When Python starts, it reads the stack size which is 8 MiB - 4 kiB. Example of gdb breakpoint on hardware_stack_limits():

    (gdb) p /x *base
    $6 = 0x7fffff7ff000
    (gdb) p /x *top
    $7 = 0x7fffffffe000
    (gdb) p 0x7fffffffe000 - 0x7fffff7ff000
    $13 = 8384512
    

    So the problem is that PyThreadState members (c_stack_top, c_stack_soft_limit, and c_stack_hard_limit) are not recomputed when setrlimit(RLIMIT_STACK) resizes the stack.

  7. NekoAsakura commented on Oct 19, 2026

    @NekoAsakura
    Member

    @encukou It works. Added after the JNI_CreateJavaVM call:

    libc = ctypes.CDLL(None)
    libc.pthread_self.restype = ctypes.c_void_p
    attr, addr, size = ctypes.create_string_buffer(128), ctypes.c_void_p(), ctypes.c_size_t()
    libc.pthread_getattr_np(ctypes.c_void_p(libc.pthread_self()), attr)
    libc.pthread_attr_getstack(attr, ctypes.byref(addr), ctypes.byref(size))
    ctypes.pythonapi.PyThreadState_Get.restype = ctypes.c_void_p
    tstate = ctypes.c_void_p(ctypes.pythonapi.PyThreadState_Get())
    print("SetStackProtection ->", ctypes.pythonapi.PyUnstable_ThreadState_SetStackProtection(tstate, addr, size), f"({size.value / 2**20:.2f} MiB)", flush=True)
    

    With OpenJDK 21.0.12.1 on Linux x86_64:

    3.15.0rc2 JVM started with default options
    SetStackProtection -> 0 (0.97 MiB)
    built
    freed
    

    Same on 3.14.7.

  8. vstinner commented on Oct 20, 2026

    @vstinner
    Member

    I can reproduce the crash using @ctriley's example (#159639 (comment)) on the Python 3.14 branch.

    # os.system(f"grep stack /proc/{os.getpid()}/maps")
    7ffdb42df000-7ffdb43db000 rw-p 00000000 00:00 0                          [stack]
    

    So when Python does crash, the stack size is 1008 kb (0x7ffdb43db000 - 0x7ffdb42df000 = 1032192 bytes). I'm not sure why Linux didn't grow to stack to the allowed limit of 8 MiB.

    gdb traces:

    $ gdb ./python -c python.309708.core
    (...)
    Program terminated with signal SIGSEGV, Segmentation fault.
    #0  _PyObject_GC_UNTRACK (filename=filename@entry=0x72b0ad "Python/gc.c", 
        lineno=lineno@entry=1835, op=op@entry=0x7fd56cbf46e0)
        at ./Include/internal/pycore_gc.h:265
    
    (gdb) p /x $rsp
    $1 = 0x7ffdb42df000
    
    (gdb) frame 3
    #3  0x00000000004f200c in _Py_Dealloc (...) at Objects/object.c:3220
    
    (gdb) p /x ((_PyThreadStateImpl *)tstate)->c_stack_soft_limit
    $2 = 0x7ffdb3beb000
    (gdb) p /x ((_PyThreadStateImpl *)tstate)->c_stack_hard_limit
    $3 = 0x7ffdb3be3000
    (gdb) p /x ((_PyThreadStateImpl *)tstate)->c_stack_top
    $4 = 0x7ffdb43da000
    

    Python expects a stack of 8156 kb ((0x7ffdb43da000 - 0x7ffdb3be3000) / 1024).

    Also, the current RLIMIT_STACK limit is 8192 kB:

    stack_size = resource.getrlimit(resource.RLIMIT_STACK)[0]
    print(f"current RLIMIT_STACK limit = {stack_size // 1024} kB")
  9. vstinner commented on Oct 20, 2026

    @vstinner
    Member

    Ah, I can also reproduce the jvm/ctypes crash on the main branch.

  10. NekoAsakura commented on Oct 20, 2026

    @NekoAsakura
    Member

    @vstinner the jvm puts guard pages at 1 MiB:

    +os.system(f"grep -B1 stack /proc/{os.getpid()}/maps")
     assert libjvm.JNI_CreateJavaVM(ctypes.byref(vm), ctypes.byref(env), ctypes.byref(args)) == 0
     print(sys.version.split()[0], "JVM started with", sys.argv[1:] or "default options", flush=True)
    +os.system(f"grep -B1 stack /proc/{os.getpid()}/maps")

    Output:

    74a4cedf7000-74a4cedf9000 rw-p 00038000 08:30 41527                      /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
    7ffef063a000-7ffef065c000 rw-p 00000000 00:00 0                          [stack]
    3.15.0rc2 JVM started with default options
    7ffef055c000-7ffef0560000 ---p 00000000 00:00 0 
    7ffef0560000-7ffef065c000 rw-p 00000000 00:00 0                          [stack]
    built
    Segmentation fault (core dumped)
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

interpreter-core(Objects, Python, Grammar, and Parser dirs)type-crashA hard crash of the interpreter, possibly with a core dump

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions