Summary
neonize/aioze/client.py passes the ctypes callback wrappers to Neonize() inline, without keeping any Python reference to them:
err = await self.__client.Neonize(
...,
func_string(self.__onQr),
func_string(self.__onLoginStatus),
func_callback_bytes(self.event.execute),
func_callback_bytes2(log_whatsmeow),
(ctypes.c_char * len(self.event.list_func)).from_buffer(d),
...
)
The thunks stay alive only because they are arguments of the in-flight FFI call. The moment Neonize() returns (e.g. after Stop()), they become garbage — but whatsmeow goroutines on the Go side (e.g. FrameSocket.readPump emitting the final "Error sending close to websocket" log line) still invoke the log/event callbacks for a short while after disconnect. Once the GC collects a thunk, the next call from Go lands on a dead function pointer and the whole host process dies with SIGSEGV during cgo execution.
Note the sync client already has a partial mitigation for exactly this (client.py: "Keep the ctypes callback wrappers and the subscriber buffer referenced for the whole lifetime of the connection", stored in self._connect_refs) — but the aioze client has none. And self._connect_refs still dies with the client object, so a host that drops the client object right after stopping it keeps a smaller version of the same window.
Crash log (production)
SIGSEGV: segmentation violation
PC=0x7f047522c0d2 m=9 sigcode=128 addr=0x0
signal arrived during cgo execution
goroutine 14 gp=0xc0003ba540 m=9 mp=0xc000436808 [syscall]:
runtime.cgocall(0x7f041d14dd00, 0xc000074e28)
github.com/krypton-byte/neonize/utils._Cfunc_call_c_func_callback_bytes2(0x7f047522c0a0, 0xc00003e280, 0x46)
_cgo_gotypes.go:54 +0x45
github.com/krypton-byte/neonize/utils.(*stdoutLogger).outputf(...)
goneonize/utils/log.go:90 +0x22c
github.com/krypton-byte/neonize/utils.(*stdoutLogger).Debugf(...)
goneonize/utils/log.go:96 +0x38
go.mau.fi/whatsmeow/socket.(*FrameSocket).readPump.func1()
whatsmeow/socket/framesocket.go:206 +0x7c
go.mau.fi/whatsmeow/socket.(*FrameSocket).readPump(...)
whatsmeow/socket/framesocket.go:216 +0x262
The crashing PC is the (freed) callback pointer itself. Trigger sequence in our host: agent teardown → Stop() → connect() returns → client object destroyed → GC frees the thunks → readPump logs its shutdown line → SIGSEGV.
Fix we are running
Keep every thunk handed to Go alive forever in a module-level registry (_LIVE_CONNECT_REFS.append(...) before calling Neonize()). The leak is a few small objects per client construction, and it is immune to both the call returning and the client object being destroyed. Running in production since 2026-07-18; the previously 100%-reproducible teardown crash is gone (survived dozens of client recycles).
Patch: devjotaduo@272f97c (branch qwenpaw-0.4.3.1). Happy to open a PR if you want it — the same treatment probably belongs in the sync client too (registry instead of instance attribute).
Related: filed separately, a race in goneonize Stop() × FetchMe that crashes/wedges never-paired devices.
Summary
neonize/aioze/client.pypasses the ctypes callback wrappers toNeonize()inline, without keeping any Python reference to them:The thunks stay alive only because they are arguments of the in-flight FFI call. The moment
Neonize()returns (e.g. afterStop()), they become garbage — but whatsmeow goroutines on the Go side (e.g.FrameSocket.readPumpemitting the final "Error sending close to websocket" log line) still invoke the log/event callbacks for a short while after disconnect. Once the GC collects a thunk, the next call from Go lands on a dead function pointer and the whole host process dies with SIGSEGV during cgo execution.Note the sync client already has a partial mitigation for exactly this (
client.py: "Keep the ctypes callback wrappers and the subscriber buffer referenced for the whole lifetime of the connection", stored inself._connect_refs) — but the aioze client has none. Andself._connect_refsstill dies with the client object, so a host that drops the client object right after stopping it keeps a smaller version of the same window.Crash log (production)
The crashing PC is the (freed) callback pointer itself. Trigger sequence in our host: agent teardown →
Stop()→connect()returns → client object destroyed → GC frees the thunks →readPumplogs its shutdown line → SIGSEGV.Fix we are running
Keep every thunk handed to Go alive forever in a module-level registry (
_LIVE_CONNECT_REFS.append(...)before callingNeonize()). The leak is a few small objects per client construction, and it is immune to both the call returning and the client object being destroyed. Running in production since 2026-07-18; the previously 100%-reproducible teardown crash is gone (survived dozens of client recycles).Patch: devjotaduo@272f97c (branch
qwenpaw-0.4.3.1). Happy to open a PR if you want it — the same treatment probably belongs in the sync client too (registry instead of instance attribute).Related: filed separately, a race in
goneonizeStop()×FetchMethat crashes/wedges never-paired devices.