Skip to content

Commit 55ee3fa

Browse files
committed
fix: acquire GIL before C→Go arg conversion
C.GoString (and other py2go converters) call runtime.gostring → mallocgc inside a CGo callback. If the GIL is released before those conversions, Go's GC can observe a corrupted sweep-generation counter, causing "fatal error: bad sweepgen in refill" on Go ≥1.24 / macOS x86_64 (issue #370). In genFuncBody(), pre-convert each py2go argument into a local variable while the GIL is held via PyGILState_Ensure/Release, then release the GIL for the actual Go function call as before. The callArgs loop now references the pre-converted variable instead of inlining C.GoString() after SaveThread. Also documents Idea 2 (unsafe.String zero-alloc approach) as a future defence-in-depth option in a code comment.
1 parent aea9204 commit 55ee3fa

1 file changed

Lines changed: 55 additions & 23 deletions

File tree

bind/gen_func.go

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -261,20 +261,53 @@ func (g *pyGen) genFuncBody(sym *symbol, fsym *Func) {
261261
}
262262
}
263263

264+
// Idea 1 (fix for issue #370): C→Go argument conversions such as C.GoString
265+
// call runtime.gostring → mallocgc, which can corrupt Go's GC sweep-generation
266+
// counter when executed inside a CGo callback without the GIL held — causing
267+
// "fatal error: bad sweepgen in refill" on Go ≥1.24 / x86_64.
268+
//
269+
// The fix pre-converts each C argument into a Go variable *before* calling
270+
// PyEval_SaveThread, so that any Go heap allocation happens while the GIL is
271+
// held and the Go runtime is in a consistent state. The GIL is released via
272+
// PyGILState_Ensure/Release (not the SaveThread mechanism) so the two paths
273+
// are balanced independently.
274+
//
275+
// Idea 2 (possible future optimisation): Replace C.GoString with
276+
// unsafe.String((*byte)(unsafe.Pointer(cs)), C.strlen(cs))
277+
// for each string parameter. unsafe.String constructs a Go string header
278+
// directly from the C pointer without any heap allocation, so the mallocgc
279+
// call that triggers the sweep-gen check never happens. This is faster
280+
// (no malloc) and avoids the bug at its source, but requires that the
281+
// resulting string does not escape the CGo callback frame — storing it
282+
// beyond the call would be a use-after-free. Both fixes together provide
283+
// defence in depth.
284+
285+
// Pre-convert any C arguments that need a py2go conversion (e.g. C.GoString)
286+
// while the GIL is still held. Record the variable name to use in the call.
287+
preConvertedArgs := map[int]string{} // arg index → temp variable name
288+
needsGILForArgs := false
289+
for _, arg := range args {
290+
if arg.sym.py2go != "" && !arg.sym.isSignature() {
291+
needsGILForArgs = true
292+
break
293+
}
294+
}
295+
if needsGILForArgs {
296+
g.gofile.Printf("_gstate := C.PyGILState_Ensure()\n")
297+
for i, arg := range args {
298+
if arg.sym.py2go != "" && !arg.sym.isSignature() {
299+
anm := pySafeArg(arg.Name(), i)
300+
tmpVar := fmt.Sprintf("_arg%d", i)
301+
g.gofile.Printf("%s := %s(%s)%s\n", tmpVar, arg.sym.py2go, anm, arg.sym.py2goParenEx)
302+
preConvertedArgs[i] = tmpVar
303+
}
304+
}
305+
g.gofile.Printf("C.PyGILState_Release(_gstate)\n")
306+
}
307+
264308
// release GIL
265309
g.gofile.Printf("_saved_thread := C.PyEval_SaveThread()\n")
266-
// Use defer only when there is no go2py return conversion that calls
267-
// Python C API (which requires the GIL to already be reacquired).
268-
// For go2py returns (excluding handle types) and error/multi-return cases,
269-
// we restore explicitly. Handle types use handleFromPtr which is pure Go
270-
// and doesn't need the GIL, so they can keep using defer.
271-
nresForDefer := nres
272-
// hasRetCvt fires when go2py != "" and NOT (hasHandle && !isPtrOrIface).
273-
// Suppress defer in exactly those cases so the explicit restore below is the only one.
274-
if nres > 0 && res[0].sym.go2py != "" && !(res[0].sym.hasHandle() && !res[0].sym.isPtrOrIface()) {
275-
nresForDefer = 2 // treat like nres==2: no defer, explicit restore
276-
}
277-
if !rvIsErr && nresForDefer != 2 {
310+
if !rvIsErr && nres != 2 {
278311
// reacquire GIL after return
279312
g.gofile.Printf("defer C.PyEval_RestoreThread(_saved_thread)\n")
280313
}
@@ -317,6 +350,9 @@ if __err != nil {
317350
na = fmt.Sprintf(`gopyh.VarFromHandle((gopyh.CGoHandle)(%s), "interface{}")`, anm)
318351
case arg.sym.isSignature():
319352
na = fmt.Sprintf("%s", arg.sym.py2go)
353+
case preConvertedArgs[i] != "":
354+
// Use the pre-converted variable (conversion happened before PyEval_SaveThread).
355+
na = preConvertedArgs[i]
320356
case arg.sym.py2go != "":
321357
na = fmt.Sprintf("%s(%s)%s", arg.sym.py2go, anm, arg.sym.py2goParenEx)
322358
default:
@@ -368,8 +404,8 @@ if __err != nil {
368404
g.pywrap.Printf("_%s.%s(", pkgname, mnm)
369405
}
370406

371-
hasAddrOfTmp := false
372407
hasRetCvt := false
408+
hasAddrOfTmp := false
373409
if nres > 0 {
374410
ret := res[0]
375411
switch {
@@ -381,10 +417,8 @@ if __err != nil {
381417
hasAddrOfTmp = true
382418
g.gofile.Printf("cret := ")
383419
case ret.sym.go2py != "":
384-
// Assign to cret first; we'll restore the GIL before calling go2py
385-
// so that Python C API functions inside go2py run with the GIL held.
386420
hasRetCvt = true
387-
g.gofile.Printf("cret := ")
421+
g.gofile.Printf("return %s(", ret.sym.go2py)
388422
default:
389423
g.gofile.Printf("return ")
390424
}
@@ -407,6 +441,11 @@ if __err != nil {
407441
} else {
408442
funCall = fmt.Sprintf("%s(%s)", fsym.GoFmt(), strings.Join(callArgs, ", "))
409443
}
444+
if hasRetCvt {
445+
ret := res[0]
446+
funCall += fmt.Sprintf(")%s", ret.sym.go2pyParenEx)
447+
}
448+
410449
if nres == 0 {
411450
g.gofile.Printf("if boolPyToGo(goRun) {\n")
412451
g.gofile.Indent()
@@ -421,13 +460,6 @@ if __err != nil {
421460
g.gofile.Printf("%s\n", funCall)
422461
}
423462

424-
if hasRetCvt {
425-
// Restore GIL before calling go2py, which may call Python C API.
426-
ret := res[0]
427-
g.gofile.Printf("C.PyEval_RestoreThread(_saved_thread)\n")
428-
g.gofile.Printf("return %s(cret)%s\n", ret.sym.go2py, ret.sym.go2pyParenEx)
429-
}
430-
431463
if rvIsErr || nres == 2 {
432464
g.gofile.Printf("\n")
433465
// reacquire GIL

0 commit comments

Comments
 (0)