Repository navigation
Description
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)
- 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.
- Drop the hardcoded
%fs:-8/%gs:0x30. Inc-sharedmode the g pointer is at a linker-assigned offset (runtime.tls_g), so-8is not guaranteed to be Go's slot. - 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 withruntime.LockOSThreadon the entry goroutine, or letneedm()establish context per entry).
Happy to build test binaries or run patches against the slidge-whatsapp repro above.
Activity
b-long commentedon Jul 22, 2026
Hi @satarsa and I'm sorry for the slow reply. This is a "nights and weekends" effort for me (and others), so I will do my best. I'm optimistic that we will be able to solve this working together.
I also want to acknowledge and sincerely thank you for this detailed bug report, it is a tremendous help. 🙇
I'm getting 503 errors for Codeberg at the moment, so I'm not able to see the full context you're linking to, but I'll try again over the coming days.
I really appreciate your list of possible solutions. Since some time has gone by, I'm wondering if you have a preferred solution. Additionally, I wonder if you might have a test case that you would be able to provide or something in a Pull Request that we could use to have more granular discussion.
b-long commentedon Jul 22, 2026
@satarsa I'm sorry for the spam, I just wanted to mention one more thing hoping that it might be helpful. Specifically, I was thinking that you might want to take a look at #394 , just as a reference example of a narrowly scoped issue & fix.
Tracing the output of the fix (for PR 394) is a little tricky. You won't find the literal
print()text like"Go empty bytes to Python: b''anywhere in a green CI log, you'd only see that in a failing test. That's because of howtestPkgBackendin main_test.go works (line ~1114): it runs the script withcmd.CombinedOutput(), captures output intobuf, and doesreflect.DeepEqual(got, want). If they match,bufis discarded — nothing is ever written tot.Logorstdout. The only thing that unconditionally prints is the genericfmt.Printf("running %s test.py\n", pyvm).satarsa commentedon Jul 22, 2026
Thanks @b-long, and no worries about the pace at all: 0% judgement, 100% understanding. Answering your two questions directly: my preferred solution is option 1 (make the clear opt-in, off by default), and below is a small patch plus the tests/verification that back it. Happy to turn this into a PR whenever you like.
My preferred solution: make
_gopy_clear_go_tlsopt-in, default offThere are two independent problems with the current unconditional clear:
c-sharedunder glibc. In a normal Go binary the runtime lays out its TLS so the g pointer sits at%fs:-8, but in ac-sharedlibrary loaded by glibc the loader owns the static TLS block and the g pointer is reached through a linker-assigned TLS variable (runtime.tls_g), not a fixed-8. Somovq $0, %fs:-8does not clear Go's g at all; it zeroes whatever thread-local happens to sit there. On CPython 3.12+ that is_Py_tss_tstate, hence the crash.The key point is that PR #393 already shipped the mechanism that actually isolates the runtimes: it loads each extension without
RTLD_GLOBAL, and gopy links each.sowith a version script that exports onlyPyInit_*. Both of these keep every Go runtime symbol (including the g-pointer TLS) local to its own.so, so nothing is shared to collide over. The raw-TLS clear is therefore redundant, and with the wrong offset it is actively harmful. Making it opt-in keeps the belt-and-suspenders available for anyone who wants it, while making the default safe.Suggested patch (against current master, 72557f6)
Adds a
-clear-go-tlsbuild flag (default false) and gates the single call site on it. The C helper and its pybindgen registration are left in place so opt-in restores today's behavior exactly; only the emitted call before each CGo entry is now conditional. TheRTLD_GLOBALhandling is untouched.The same two lines (flag declaration +
bind.ClearGoTLS = ...) are added to each ofcmd_build.go,cmd_gen.go,cmd_pkg.goandcmd_exe.go, mirroring the existingno-makeflag, e.g.:@@ cmd_build.go cmd.Flag.Bool("no-make", false, "do not generate a Makefile, e.g., when called from Makefile") + cmd.Flag.Bool("clear-go-tls", false, "emit a _gopy_clear_go_tls() call before every CGo entry (issue #370); off by default, needed only when several gopy extensions share one process and known to crash CPython 3.12+ (issue #395)") cmd.Flag.Bool("dynamic-link", false, "whether to link output shared library dynamically to Python") @@ bind.NoWarn = cfg.NoWarn bind.NoMake = cfg.NoMake + bind.ClearGoTLS = cmdr.Flag.Lookup("clear-go-tls").Value.Get().(bool)gofmtandgo vet ./...are clean, andgo test ./bind/passes.Verification on the real downstream project
Environment: Gentoo, glibc, distro CPython 3.14.6, go 1.26.4, gcc 16, gopy master 72557f6. Rebuilding slidge-whatsapp's extension with the patched gopy:
whatsapp.pycontains zero_gopy_clear_go_tls()call sites, and the first call now succeeds:gopy build -clear-go-tls(opt-in): the call reappears (30 sites, one per CGo entry) and the crash returns exactly as before:So the flag is a faithful toggle of the old behavior, and off-by-default removes the crash with no post-build patching.
Why off-by-default is safe for the multi-extension case too
Two independent checks, both with the clear off:
gc.collect()(the exact condition "broken" since go 1.21 #370 describes):.soexports exactly one global dynamic symbol and no Go runtime symbols at all:Because
runtime.tls_gand friends are local to each.so, the g pointer is per-runtime-private and cannot be interposed across extensions, independently ofRTLD_GLOBAL. This is what removes the premise behind the clear.I have to emphasize: I do not have the original #370 multi-extension reproducer, so I cannot claim to have re-run that exact case. The opt-in keeps the old behavior one flag away for anyone who needs it, and I am glad to validate against the #370 repro if you can point me at it.
Suggested regression tests
Matching how
testPkgBackendruns (python test.py, compare output, a segfault shows up as a non-zero exit):_gopy_clear_go_tls(), and with-clear-go-tlsthat it does. This guards the default without depending on any particular CPython TLS layout._examplespackage pair built as two extensions, imported together, cross-called undergc.collect(), asserting a clean run. This is the multi-runtime path that Fix memory leaks & crashes when loading multiple Go extensions in one Python process #393 was protecting; keeping it green with the clear off documents that the isolation comes from RTLD_GLOBAL-local loading plus the version script, not from the TLS poke.I can include both in the PR.
Downstream slidge-whatsapp
This morning (8 UTC) when I was reading your responses, codeberg was really down (such an irony...), now it seems to be back in operation. For slidge-whatsapp (https://codeberg.org/slidge/slidge-whatsapp/issues/153): until a gopy release carries this, the interim fix is a post-build NOP of the single
movq $0, %fs:-8(13 bytes -> NOPs), which is already running in production at my server. Once this lands, the clean path is to drop that patch and simply build with the default (clear off). And I can forget about it, finally and forever...b-long commentedon Jul 22, 2026
@satarsa Thank you for the detailed message. I think this is a reasonable approach, and if you can open a PR that'd be a massive help.
I have an idea about an independent reproducer (build and execute) test, and when I find time to experiment I'll be sure to share the details.
I'll watch for your PR, thank you again.
bind: make _gopy_clear_go_tls opt-in, off by default
feat: add '3.12' testing & improve complex conversion (#399)