From 68f5fadf92c4af3975e708bfe10025c95a202383 Mon Sep 17 00:00:00 2001 From: b-long Date: Thu, 23 Jul 2026 21:13:48 -0400 Subject: [PATCH 1/4] feat: matrix build `python-version: ['3.11', '3.12']` --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ada2439..af0017d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,13 +18,14 @@ env: jobs: build: - name: Build + name: Build (${{ matrix.platform }}, Go ${{ matrix.go-version }}, Python ${{ matrix.python-version }}) strategy: fail-fast: false matrix: # TODO: Consider official support matrix (OS and Go versions) and adjust this matrix accordingly go-version: [1.25.x, 1.24.x, 1.23.x, 1.22.x] platform: [ubuntu-latest, windows-latest, macos-15] + python-version: ['3.11', '3.12'] runs-on: ${{ matrix.platform }} steps: - name: Checkout code @@ -33,7 +34,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: ${{ matrix.python-version }} - name: Install Go uses: actions/setup-go@v5 From 1273f4657f4c02c2d87ce1fe06ac1c5d0563e776 Mon Sep 17 00:00:00 2001 From: Vadim Dyadkin Date: Thu, 23 Jul 2026 11:10:36 +0200 Subject: [PATCH 2/4] bind: make _gopy_clear_go_tls opt-in, off by default The unconditional _gopy_clear_go_tls() call (issue #370) performs a hardcoded TLS store (movq $0, %fs:-8 on linux/amd64). On glibc + CPython 3.12+ that offset overlaps CPython's current-thread-state TLS slot, so the store nulls it and the interpreter segfaults on the first CGo entry in the common single-extension case (issue #395). Add a -clear-go-tls build flag (default false) and gate the single call site on it. The C helper and its pybindgen registration are left in place so opt-in restores the previous behavior exactly. RTLD_GLOBAL-local loading, which is what actually isolates each runtime's goroutine-pointer TLS, is untouched and remains unconditional. Fixes #395 --- bind/gen.go | 24 ++++++++++++++++++------ bind/gen_func.go | 9 +++++++-- cmd_build.go | 2 ++ cmd_exe.go | 2 ++ cmd_gen.go | 2 ++ cmd_pkg.go | 2 ++ 6 files changed, 33 insertions(+), 8 deletions(-) diff --git a/bind/gen.go b/bind/gen.go index 97c1ade..f6d8604 100644 --- a/bind/gen.go +++ b/bind/gen.go @@ -329,12 +329,13 @@ cwd = os.getcwd() currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) os.chdir(currentdir) # When multiple gopy extensions coexist in one Python process each carries its own -# independent Go runtime. The Go extension is loaded without RTLD_GLOBAL below, and -# _gopy_clear_go_tls() is called before each CGo entry to force needm() to run, which -# establishes the correct per-extension M/P/mcache context (issue #370). -# Also load the extension without RTLD_GLOBAL so that Go runtime symbols stay -# local to each .so — belt-and-suspenders on platforms where RTLD_GLOBAL is the -# Python default (e.g. some Linux builds). +# independent Go runtime. Loading each extension without RTLD_GLOBAL below keeps its +# Go runtime symbols (including the per-runtime goroutine-pointer TLS slot) local to +# its own .so, which is what prevents the runtimes from colliding (issue #370). +# A _gopy_clear_go_tls() call before each CGo entry is available as an extra safety +# net but is opt-in (gopy build -clear-go-tls), off by default: on glibc + CPython +# 3.12+ its hardcoded TLS store clobbers the interpreter thread state and crashes on +# the first call in the common single-extension case (issue #395). if hasattr(sys, 'getdlopenflags'): try: import ctypes as _gopy_ctypes @@ -518,6 +519,17 @@ var NoWarn = false // NoMake turns off generation of Makefiles var NoMake = false +// ClearGoTLS controls whether generated wrappers emit a _gopy_clear_go_tls() +// call before every CGo entry point (issue #370). It is opt-in and off by +// default. The clear performs a hardcoded TLS store (movq $0, %gs:0x30 on +// darwin/amd64, movq $0, %fs:-8 on linux/amd64); with a single gopy extension +// in the process it is unnecessary, and on glibc + CPython 3.12+ that offset +// overlaps the TLS slot CPython uses for the current thread state, so the store +// nulls it and the interpreter segfaults on the first call (issue #395). +// Loading each extension without RTLD_GLOBAL, done unconditionally, already +// keeps each runtime's goroutine-pointer TLS local to its own .so. +var ClearGoTLS = false + // GenPyBind generates a .go file, build.py file to enable pybindgen to create python bindings, // and wrapper .py file(s) that are loaded as the interface to the package with shadow // python-side classes diff --git a/bind/gen_func.go b/bind/gen_func.go index fc2ee15..b264334 100644 --- a/bind/gen_func.go +++ b/bind/gen_func.go @@ -339,8 +339,13 @@ if __err != nil { // Clear the Go TLS goroutine slot before the CGo entry point so that // Go's needm() runs and establishes the correct per-extension context. // Without this, two extensions sharing the same process can corrupt - // each other's heap via TLS collision (issue #370). - g.pywrap.Printf("_%s._gopy_clear_go_tls()\n", pkgname) + // each other's heap via TLS collision (issue #370). Opt-in and off by + // default: the hardcoded TLS store crashes CPython 3.12+ in the common + // single-extension case (issue #395), and RTLD_GLOBAL-local loading + // already isolates each runtime's goroutine-pointer TLS. + if ClearGoTLS { + g.pywrap.Printf("_%s._gopy_clear_go_tls()\n", pkgname) + } // pywrap output mnm := fsym.ID() diff --git a/cmd_build.go b/cmd_build.go index 3def118..7e38997 100644 --- a/cmd_build.go +++ b/cmd_build.go @@ -45,6 +45,7 @@ ex: cmd.Flag.Bool("symbols", true, "include symbols in output") cmd.Flag.Bool("no-warn", false, "suppress warning messages, which may be expected") 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") cmd.Flag.String("build-tags", "", "build tags to be passed to `go build`") return cmd @@ -72,6 +73,7 @@ func gopyRunCmdBuild(cmdr *commander.Command, args []string) error { bind.NoWarn = cfg.NoWarn bind.NoMake = cfg.NoMake + bind.ClearGoTLS = cmdr.Flag.Lookup("clear-go-tls").Value.Get().(bool) for _, path := range args { bpkg, err := loadPackage(path, true, cfg.BuildTags) // build first diff --git a/cmd_exe.go b/cmd_exe.go index 60ee3ce..acbd51e 100644 --- a/cmd_exe.go +++ b/cmd_exe.go @@ -58,6 +58,7 @@ ex: cmd.Flag.String("url", "https://github.com/go-python/gopy", "home page for project") cmd.Flag.Bool("no-warn", false, "suppress warning messages, which may be expected") 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") cmd.Flag.String("build-tags", "", "build tags to be passed to `go build`") @@ -97,6 +98,7 @@ func gopyRunCmdExe(cmdr *commander.Command, args []string) error { bind.NoWarn = cfg.NoWarn bind.NoMake = cfg.NoMake + bind.ClearGoTLS = cmdr.Flag.Lookup("clear-go-tls").Value.Get().(bool) if cfg.Name == "" { path := args[0] diff --git a/cmd_gen.go b/cmd_gen.go index becd0f1..8b76735 100644 --- a/cmd_gen.go +++ b/cmd_gen.go @@ -37,6 +37,7 @@ ex: cmd.Flag.Bool("rename", false, "rename Go symbols to python PEP snake_case") cmd.Flag.Bool("no-warn", false, "suppress warning messages, which may be expected") 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") cmd.Flag.String("build-tags", "", "build tags to be passed to `go build`") return cmd @@ -69,6 +70,7 @@ func gopyRunCmdGen(cmdr *commander.Command, args []string) error { bind.NoWarn = cfg.NoWarn bind.NoMake = cfg.NoMake + bind.ClearGoTLS = cmdr.Flag.Lookup("clear-go-tls").Value.Get().(bool) for _, path := range args { bpkg, err := loadPackage(path, true, cfg.BuildTags) // build first diff --git a/cmd_pkg.go b/cmd_pkg.go index 0f58480..9891907 100644 --- a/cmd_pkg.go +++ b/cmd_pkg.go @@ -55,6 +55,7 @@ ex: cmd.Flag.String("url", "https://github.com/go-python/gopy", "home page for project") cmd.Flag.Bool("no-warn", false, "suppress warning messages, which may be expected") 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") cmd.Flag.String("build-tags", "", "build tags to be passed to `go build`") @@ -93,6 +94,7 @@ func gopyRunCmdPkg(cmdr *commander.Command, args []string) error { bind.NoWarn = cfg.NoWarn bind.NoMake = cfg.NoMake + bind.ClearGoTLS = cmdr.Flag.Lookup("clear-go-tls").Value.Get().(bool) if cfg.Name == "" { path := args[0] From d41db33699c93239969e74e9afe1effe006c802b Mon Sep 17 00:00:00 2001 From: b-long Date: Thu, 23 Jul 2026 22:39:01 -0400 Subject: [PATCH 3/4] fix complex conversion crash on CPython 3.12 Ensure GIL state before allocating; fixes stale thread-state segfault. --- bind/gen.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bind/gen.go b/bind/gen.go index f6d8604..fe96bd6 100644 --- a/bind/gen.go +++ b/bind/gen.go @@ -194,7 +194,10 @@ func boolPyToGo(b C.char) bool { } func complex64GoToPy(c complex64) *C.PyObject { - return C.PyComplex_FromDoubles(C.double(real(c)), C.double(imag(c))) + gstate := C.PyGILState_Ensure() + obj := C.PyComplex_FromDoubles(C.double(real(c)), C.double(imag(c))) + C.PyGILState_Release(gstate) + return obj } func complex64PyToGo(o *C.PyObject) complex64 { @@ -203,7 +206,10 @@ func complex64PyToGo(o *C.PyObject) complex64 { } func complex128GoToPy(c complex128) *C.PyObject { - return C.PyComplex_FromDoubles(C.double(real(c)), C.double(imag(c))) + gstate := C.PyGILState_Ensure() + obj := C.PyComplex_FromDoubles(C.double(real(c)), C.double(imag(c))) + C.PyGILState_Release(gstate) + return obj } func complex128PyToGo(o *C.PyObject) complex128 { From b46aa9e9c8cd5328fe3d5f32338e7cb0e32dad6e Mon Sep 17 00:00:00 2001 From: b-long Date: Thu, 23 Jul 2026 22:53:49 -0400 Subject: [PATCH 4/4] Revert "bind: make _gopy_clear_go_tls opt-in, off by default" This reverts commit 1273f4657f4c02c2d87ce1fe06ac1c5d0563e776. --- bind/gen.go | 24 ++++++------------------ bind/gen_func.go | 9 ++------- cmd_build.go | 2 -- cmd_exe.go | 2 -- cmd_gen.go | 2 -- cmd_pkg.go | 2 -- 6 files changed, 8 insertions(+), 33 deletions(-) diff --git a/bind/gen.go b/bind/gen.go index fe96bd6..a4623f2 100644 --- a/bind/gen.go +++ b/bind/gen.go @@ -335,13 +335,12 @@ cwd = os.getcwd() currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) os.chdir(currentdir) # When multiple gopy extensions coexist in one Python process each carries its own -# independent Go runtime. Loading each extension without RTLD_GLOBAL below keeps its -# Go runtime symbols (including the per-runtime goroutine-pointer TLS slot) local to -# its own .so, which is what prevents the runtimes from colliding (issue #370). -# A _gopy_clear_go_tls() call before each CGo entry is available as an extra safety -# net but is opt-in (gopy build -clear-go-tls), off by default: on glibc + CPython -# 3.12+ its hardcoded TLS store clobbers the interpreter thread state and crashes on -# the first call in the common single-extension case (issue #395). +# independent Go runtime. The Go extension is loaded without RTLD_GLOBAL below, and +# _gopy_clear_go_tls() is called before each CGo entry to force needm() to run, which +# establishes the correct per-extension M/P/mcache context (issue #370). +# Also load the extension without RTLD_GLOBAL so that Go runtime symbols stay +# local to each .so — belt-and-suspenders on platforms where RTLD_GLOBAL is the +# Python default (e.g. some Linux builds). if hasattr(sys, 'getdlopenflags'): try: import ctypes as _gopy_ctypes @@ -525,17 +524,6 @@ var NoWarn = false // NoMake turns off generation of Makefiles var NoMake = false -// ClearGoTLS controls whether generated wrappers emit a _gopy_clear_go_tls() -// call before every CGo entry point (issue #370). It is opt-in and off by -// default. The clear performs a hardcoded TLS store (movq $0, %gs:0x30 on -// darwin/amd64, movq $0, %fs:-8 on linux/amd64); with a single gopy extension -// in the process it is unnecessary, and on glibc + CPython 3.12+ that offset -// overlaps the TLS slot CPython uses for the current thread state, so the store -// nulls it and the interpreter segfaults on the first call (issue #395). -// Loading each extension without RTLD_GLOBAL, done unconditionally, already -// keeps each runtime's goroutine-pointer TLS local to its own .so. -var ClearGoTLS = false - // GenPyBind generates a .go file, build.py file to enable pybindgen to create python bindings, // and wrapper .py file(s) that are loaded as the interface to the package with shadow // python-side classes diff --git a/bind/gen_func.go b/bind/gen_func.go index b264334..fc2ee15 100644 --- a/bind/gen_func.go +++ b/bind/gen_func.go @@ -339,13 +339,8 @@ if __err != nil { // Clear the Go TLS goroutine slot before the CGo entry point so that // Go's needm() runs and establishes the correct per-extension context. // Without this, two extensions sharing the same process can corrupt - // each other's heap via TLS collision (issue #370). Opt-in and off by - // default: the hardcoded TLS store crashes CPython 3.12+ in the common - // single-extension case (issue #395), and RTLD_GLOBAL-local loading - // already isolates each runtime's goroutine-pointer TLS. - if ClearGoTLS { - g.pywrap.Printf("_%s._gopy_clear_go_tls()\n", pkgname) - } + // each other's heap via TLS collision (issue #370). + g.pywrap.Printf("_%s._gopy_clear_go_tls()\n", pkgname) // pywrap output mnm := fsym.ID() diff --git a/cmd_build.go b/cmd_build.go index 7e38997..3def118 100644 --- a/cmd_build.go +++ b/cmd_build.go @@ -45,7 +45,6 @@ ex: cmd.Flag.Bool("symbols", true, "include symbols in output") cmd.Flag.Bool("no-warn", false, "suppress warning messages, which may be expected") 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") cmd.Flag.String("build-tags", "", "build tags to be passed to `go build`") return cmd @@ -73,7 +72,6 @@ func gopyRunCmdBuild(cmdr *commander.Command, args []string) error { bind.NoWarn = cfg.NoWarn bind.NoMake = cfg.NoMake - bind.ClearGoTLS = cmdr.Flag.Lookup("clear-go-tls").Value.Get().(bool) for _, path := range args { bpkg, err := loadPackage(path, true, cfg.BuildTags) // build first diff --git a/cmd_exe.go b/cmd_exe.go index acbd51e..60ee3ce 100644 --- a/cmd_exe.go +++ b/cmd_exe.go @@ -58,7 +58,6 @@ ex: cmd.Flag.String("url", "https://github.com/go-python/gopy", "home page for project") cmd.Flag.Bool("no-warn", false, "suppress warning messages, which may be expected") 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") cmd.Flag.String("build-tags", "", "build tags to be passed to `go build`") @@ -98,7 +97,6 @@ func gopyRunCmdExe(cmdr *commander.Command, args []string) error { bind.NoWarn = cfg.NoWarn bind.NoMake = cfg.NoMake - bind.ClearGoTLS = cmdr.Flag.Lookup("clear-go-tls").Value.Get().(bool) if cfg.Name == "" { path := args[0] diff --git a/cmd_gen.go b/cmd_gen.go index 8b76735..becd0f1 100644 --- a/cmd_gen.go +++ b/cmd_gen.go @@ -37,7 +37,6 @@ ex: cmd.Flag.Bool("rename", false, "rename Go symbols to python PEP snake_case") cmd.Flag.Bool("no-warn", false, "suppress warning messages, which may be expected") 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") cmd.Flag.String("build-tags", "", "build tags to be passed to `go build`") return cmd @@ -70,7 +69,6 @@ func gopyRunCmdGen(cmdr *commander.Command, args []string) error { bind.NoWarn = cfg.NoWarn bind.NoMake = cfg.NoMake - bind.ClearGoTLS = cmdr.Flag.Lookup("clear-go-tls").Value.Get().(bool) for _, path := range args { bpkg, err := loadPackage(path, true, cfg.BuildTags) // build first diff --git a/cmd_pkg.go b/cmd_pkg.go index 9891907..0f58480 100644 --- a/cmd_pkg.go +++ b/cmd_pkg.go @@ -55,7 +55,6 @@ ex: cmd.Flag.String("url", "https://github.com/go-python/gopy", "home page for project") cmd.Flag.Bool("no-warn", false, "suppress warning messages, which may be expected") 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") cmd.Flag.String("build-tags", "", "build tags to be passed to `go build`") @@ -94,7 +93,6 @@ func gopyRunCmdPkg(cmdr *commander.Command, args []string) error { bind.NoWarn = cfg.NoWarn bind.NoMake = cfg.NoMake - bind.ClearGoTLS = cmdr.Flag.Lookup("clear-go-tls").Value.Get().(bool) if cfg.Name == "" { path := args[0]