Skip to content

#370's _gopy_clear_go_tls (movq $0, %fs:-8) hard-crashes CPython 3.12+ in a shipping extension #395

Description

@satarsa

Thanks for gopy. This is a follow-up to #370 and its fix, PR #393 ("Fix memory leaks & crashes when loading multiple Go extensions in one Python process", merged 2026-06-02), which introduced _gopy_clear_go_tls(). That workaround is now a hard, deterministic crash for a real, released project. slidge-whatsapp (a WhatsApp/XMPP gateway, https://codeberg.org/slidge/slidge-whatsapp) segfaults CPython on the first call into its gopy extension. Full downstream investigation: https://codeberg.org/slidge/slidge-whatsapp/issues/153

PR #393 solved a real multi-extension case, but the TLS clear is applied unconditionally, and in the single-extension case (which is the common one) it does the opposite: it corrupts CPython's own thread state. Concrete details below; I am glad to test any patch.

Reproduction (real project, deterministic, no network/credentials)

python3.12 -m venv venv
venv/bin/pip install slidge-whatsapp==0.4.0b1      # ships a gopy-built wheel
venv/bin/python -c "import slidge_whatsapp.generated.whatsapp as w; w.NewGateway()"
Segmentation fault (core dumped)      # exit 139, every time, on the very first call

Reproduced on Gentoo, glibc, distro CPython 3.12 / 3.13 / 3.14 (identical), go 1.26.x, with gopy pinned at v0.4.11-0.20241206185020-5f285b890023. It does NOT crash on slidge-whatsapp 0.3.11b1, which predates _gopy_clear_go_tls (zero occurrences of it, of RequestGC, _gcReq, gc.callbacks); every build after it crashes.

Backtrace

Thread 1 "python" received signal SIGSEGV
#0  _PyType_GetDict               (libpython3.12.so.1.0)
#1  _PyObject_GenericGetAttrWithDict
#3  PyObject_GetAttr
#4  _PyEval_EvalFrameDefault
...
si_addr = 0x10
rip     = _PyType_GetDict+48:  mov 0x10(%rdx),%rcx
rdx     = 0x0                   # NULL

rdx is the current thread-state / interpreter pointer. It is NULL right after the CGo call returns and Python bytecode resumes, so 0x10(%rdx) faults at address 0x10 (the "segfault at 10" seen in dmesg).

Root cause

The generated Python wrapper calls _whatsapp._gopy_clear_go_tls() before every CGo entry. On linux/amd64 its body is:

static void _gopy_clear_go_tls(void) {
#elif defined(__x86_64__) && defined(__linux__)
    __asm__ volatile("movq $0, %%fs:-8" ::: "memory");
#endif
}

CPython 3.12 moved the current-thread-state fast path into a thread-local variable (_Py_tss_tstate, a _Py_thread_local PyThreadState *). It lives in the static TLS block, i.e. at a negative offset from the %fs thread pointer that the linker/loader assigns. The comment on _gopy_clear_go_tls assumes %fs:-8 is Go's private "current g" slot; that is true for Go's internal TLS layout, but when Go is built c-shared and loaded through glibc, the g pointer is reached via a linker-assigned TLS variable (runtime.tls_g), not a fixed -8. glibc owns the TCB and the static TLS block; Go's g slot and CPython's _Py_tss_tstate both live there at loader-chosen offsets. Writing a hardcoded %fs:-8 therefore clobbers whatever thread-local sits at that offset: here, CPython's current thread state, so _PyThreadState_GET() returns NULL and the interpreter crashes.

Why it slips through CI ("works on my machine")

The colliding offset depends on the exact static-TLS layout, so the same .so crashes on one CPython build and not another. slidge-whatsapp's CI builds the wheel against a standalone CPython (the cgo preamble hardcodes -I/root/.local/share/uv/python/...), which is also CI's runtime, and there %fs:-8 does not collide. Users install that same wheel against their distro CPython, where it does. That is exactly why #153 kept getting "cannot reproduce" while some users on a distro Python hit it.

Proof it is this instruction

Overwriting the single movq $0, %fs:-8 (bytes 64 48 c7 04 25 f8 ff ff ff 00 00 00 00, 13 bytes) with NOPs in the exact crashing .so removes the crash entirely; the extension then runs under real traffic. I fixed it for my particular installation using a post-build patch, but I believe it is a workaround for something that should be handled in gopy.

Possible directions (suggestions)

  1. Make the TLS clear opt-in, or skip it unless more than one gopy runtime is actually present, so the single-extension case (probably the majority) is safe by default.
  2. Drop the hardcoded %fs:-8 / %gs:0x30. In c-shared mode the g pointer is at a linker-assigned offset (runtime.tls_g), so -8 is not guaranteed to be Go's slot.
  3. If the intent is to force needm() for a foreign thread, prefer a mechanism that does not poke raw TLS from C (e.g. pin with runtime.LockOSThread on the entry goroutine, or let needm() establish context per entry).

Happy to build test binaries or run patches against the slidge-whatsapp repro above.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Fields

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions