#865·py-spy

Wrong interpreter base when libffi static trampolines remap a page of the python binary (uv / python-build-standalone)

Author: ivan-botcoCreated Aug 19, 2026Updated Aug 19, 2026

Summary

CPython builds that statically link libffi >= 3.4 (e.g. python-build-standalone, as installed by uv) gain a second executable mapping of the python binary once the process creates its first ctypes/cffi callback: libffi's static-trampoline code (src/tramp.c, libffi/libffi#624) re-mmaps the page of the file containing its trampoline table — which is the python executable itself when statically linked. PythonProcessInfo::new takes the first executable map matching the binary path; the trampoline page has the lowest address, so it wins and the base is wrong. Attach then fails (Bad address (os error 14) on 0.4.0, Failed to copy Py_Version symbol on 0.4.2, sometimes Failed to find a python version from target process). Forked children inherit the mapping, and import numba or threadpoolctl.threadpool_info() is enough to create the callback.

Repro

python
# repro.py — python 3.11.14 from uv (cpython-3.11.14-linux-x86_64-gnu), py-spy 0.3.14/0.4.0/0.4.2, linux x86_64
import ctypes, os, time
ctypes.CDLL(None).prctl(0x59616D61, ctypes.c_ulong(-1), 0, 0, 0)  # PR_SET_PTRACER_ANY, only for Yama
print("pid:", os.getpid(), flush=True)
time.sleep(15)                            # py-spy dump --pid <pid> works here
ctypes.CFUNCTYPE(ctypes.c_int)(lambda: 0)
time.sleep(300)                           # py-spy dump --pid <pid> now fails

(py-spy record -- python ... doesn't reproduce — it attaches during startup, before the callback exists, and keeps the good base. Attach-after is deterministic.)

Evidence

$ grep python3.11 /proc/<pid>/maps        # after the callback; first line is new
14dd3062c000-14dd3062d000 r-xp 00604000  .../bin/python3.11   <-- libffi trampoline page
555ad0ec2000-555ad109f000 r--p 00000000  .../bin/python3.11
555ad109f000-555ad1a63000 r-xp 001dd000  .../bin/python3.11   <-- real text segment
...
$ nm .../bin/python3.11 | grep trampoline_code_table
0000000000604000 t trampoline_code_table                       <-- matches the mapping offset

RUST_LOG=info: got symbol Py_Version (0x00001463f4dfa898)      <-- rebased against 0x14dd..., not 0x55...

Dynamically-linked-libffi CPythons are unaffected (the page is backed by libffi.so.8, which doesn't match the binary path).

Suggested fix

Among maps matching is_python_bin && is_exec, prefer the one with the lowest file offset (the real text segment; the trampoline page has a large non-zero offset) instead of the first by address. Same for the libpython map selection.