Skip to content

Commit d2b03d1

Browse files
authored
Merge pull request #37 from xianml/fix/issue-30-signal-handlers
fix(#30): preserve Go's signal handlers across chdb_connect; add race detector to CI
2 parents 477f6e2 + aa13e1b commit d2b03d1

5 files changed

Lines changed: 451 additions & 0 deletions

File tree

‎.github/workflows/chdb.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ jobs:
2828
make build
2929
- name: Test
3030
run: make test
31+
- name: Test with race detector
32+
run: go test -race -timeout=180s ./...
3133
- name: Test main
3234
run: ./chdb-go "SELECT 12345"
3335

@@ -48,6 +50,8 @@ jobs:
4850
make build
4951
- name: Test
5052
run: make test
53+
- name: Test with race detector
54+
run: go test -race -timeout=180s ./...
5155
- name: Test main
5256
run: ./chdb-go "SELECT 12345"
5357

‎chdb-purego/binding.go‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,75 @@ package chdbpurego
33
import (
44
"os"
55
"os/exec"
6+
"syscall"
67
"unsafe"
78

89
"github.com/ebitengine/purego"
910
)
1011

12+
// sigactionBufSize is large enough to hold a struct sigaction on every
13+
// platform we care about: 16 B on macOS, ~152 B on glibc/Linux.
14+
const sigactionBufSize = 256
15+
16+
// signalsToProtect lists the signals the Go runtime owns and must keep
17+
// owning. libchdb's chdb_set_signal_handlers_enabled(0) wipes the kernel's
18+
// handler list for the same set as a side effect; we save and restore these
19+
// around that call so Go's handlers survive.
20+
//
21+
// Signal numbers are NOT the same on Linux and macOS (e.g. SIGBUS is 10 on
22+
// Darwin but 7 on Linux; SIGURG is 16 on Darwin but 23 on Linux). Use the
23+
// syscall package's per-platform constants so the values resolve correctly
24+
// at compile time on each OS.
25+
var signalsToProtect = []int{
26+
int(syscall.SIGILL),
27+
int(syscall.SIGABRT),
28+
int(syscall.SIGFPE),
29+
int(syscall.SIGBUS),
30+
int(syscall.SIGSEGV),
31+
int(syscall.SIGURG), // Go uses SIGURG for async preemption
32+
}
33+
34+
// libcSigaction is the libc sigaction(2) function, resolved from whichever
35+
// libc the process was already linked against. We pass opaque buffers
36+
// rather than typed structs so the same code works for the differently-
37+
// laid-out struct sigaction on macOS vs glibc.
38+
var libcSigaction func(sig int, act, oact unsafe.Pointer) int
39+
40+
func loadSigaction() {
41+
// Prefer the empty-path form: on Linux (glibc, musl) dlopen("") returns
42+
// a handle to the running process's symbol table, which always contains
43+
// sigaction because libc is already loaded by the Go runtime. No path
44+
// to maintain.
45+
libc, err := purego.Dlopen("", purego.RTLD_NOW)
46+
if err != nil {
47+
// macOS dyld interprets "" as a file lookup, not as "current
48+
// process", so the empty-path form fails. Fall back to libSystem,
49+
// which is always present and always contains sigaction.
50+
libc, err = purego.Dlopen("/usr/lib/libSystem.B.dylib", purego.RTLD_NOW)
51+
}
52+
if err != nil {
53+
panic("chdb-purego: cannot resolve sigaction(2): " + err.Error())
54+
}
55+
purego.RegisterLibFunc(&libcSigaction, libc, "sigaction")
56+
}
57+
58+
// snapshotSignalHandlers stores the current sigaction state for the signals
59+
// we need to protect, in opaque buffers.
60+
func snapshotSignalHandlers() [][sigactionBufSize]byte {
61+
saved := make([][sigactionBufSize]byte, len(signalsToProtect))
62+
for i, sig := range signalsToProtect {
63+
libcSigaction(sig, nil, unsafe.Pointer(&saved[i][0]))
64+
}
65+
return saved
66+
}
67+
68+
// restoreSignalHandlers writes a previously-snapshotted sigaction state back.
69+
func restoreSignalHandlers(saved [][sigactionBufSize]byte) {
70+
for i, sig := range signalsToProtect {
71+
libcSigaction(sig, unsafe.Pointer(&saved[i][0]), nil)
72+
}
73+
}
74+
1175
func findLibrary() string {
1276
// Env var
1377
if envPath := os.Getenv("CHDB_LIB_PATH"); envPath != "" {
@@ -66,6 +130,13 @@ var (
66130
chdbResultStorageRowsRead func(result *chdb_result) uint64
67131
chdbResultStorageBytesRead func(result *chdb_result) uint64
68132
chdbResultError func(result *chdb_result) string
133+
134+
// Process-wide signal handler control. See issue #30: by default libchdb
135+
// installs its own SIGSEGV/SIGABRT/SIGBUS/SIGILL handlers when a
136+
// connection is opened, which overwrites the Go runtime's handlers and
137+
// breaks async preemption (SIGURG) and stack-growth (SIGSEGV) handling.
138+
chdbSetSignalHandlersEnabled func(enabled int)
139+
chdbResetSignalHandlers func()
69140
)
70141

71142
func init() {
@@ -105,4 +176,59 @@ func init() {
105176
purego.RegisterLibFunc(&chdbResultStorageBytesRead, libchdb, "chdb_result_storage_bytes_read")
106177
purego.RegisterLibFunc(&chdbResultError, libchdb, "chdb_result_error")
107178

179+
// Signal handler protection (issue #30). The required API was added in
180+
// libchdb v26.x via chdb-core#11. Pre-check the symbol with Dlsym so
181+
// that older libchdb builds — which don't export
182+
// chdb_set_signal_handlers_enabled — degrade gracefully instead of
183+
// panicking inside RegisterLibFunc at init. On those builds the crash
184+
// from issue #30 stays unfixed, but the binding still loads.
185+
if sym, dlsymErr := purego.Dlsym(libchdb, "chdb_set_signal_handlers_enabled"); dlsymErr == nil && sym != 0 {
186+
purego.RegisterLibFunc(&chdbSetSignalHandlersEnabled, libchdb, "chdb_set_signal_handlers_enabled")
187+
purego.RegisterLibFunc(&chdbResetSignalHandlers, libchdb, "chdb_reset_signal_handlers")
188+
189+
// Tell libchdb NOT to install its own signal handlers on the
190+
// first chdb_connect(). Without this, libchdb's ClickHouse
191+
// daemon code installs handlers for SIGSEGV / SIGABRT / SIGBUS
192+
// / SIGILL / SIGFPE the first time a connection is opened,
193+
// overwriting the handlers the Go runtime relies on for stack
194+
// growth and panic recovery. When a Go signal then lands in
195+
// libchdb's handler instead of the runtime's, the result is
196+
// the rare std::mutex::unlock crash described in issue #30 —
197+
// most often on macOS arm64 CI where signal pressure is
198+
// highest.
199+
//
200+
// Important: chdb_set_signal_handlers_enabled(0) doesn't only
201+
// set a flag — it also wipes the existing handlers in the same
202+
// set back to SIG_DFL (verified empirically; the chdb.h header
203+
// doesn't mention this side effect). Since the Go runtime has
204+
// already installed its handlers by the time this package's
205+
// init runs, we have to save Go's handlers, call the disable,
206+
// then restore them. After this dance the libchdb flag is set
207+
// (so no future connect installs handlers) and Go's handlers
208+
// remain in place.
209+
loadSigaction()
210+
func() {
211+
defer guardSignalHandlers()()
212+
chdbSetSignalHandlersEnabled(0)
213+
}()
214+
}
215+
}
216+
217+
// signalProtectionAvailable reports whether the running libchdb exposes the
218+
// signal handler control API. When false, snapshotSignalHandlers and
219+
// restoreSignalHandlers must NOT be called (libcSigaction was not loaded).
220+
func signalProtectionAvailable() bool {
221+
return chdbSetSignalHandlersEnabled != nil
222+
}
223+
224+
// guardSignalHandlers returns a function suitable for `defer`-ing around a
225+
// call into libchdb. When signal protection is available, it captures the
226+
// current sigaction state now and the returned closure restores it. When
227+
// not available (old libchdb), both calls are no-ops.
228+
func guardSignalHandlers() func() {
229+
if !signalProtectionAvailable() {
230+
return func() {}
231+
}
232+
saved := snapshotSignalHandlers()
233+
return func() { restoreSignalHandlers(saved) }
108234
}

‎chdb-purego/chdb.go‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,23 @@ func NewConnection(argc int, argv []string) (ChdbConn, error) {
217217
var conn *chdb_connection
218218
var err error
219219
func() {
220+
// chdb_connect — even after chdb_set_signal_handlers_enabled(0) —
221+
// still resets the kernel sigaction table for SIGSEGV / SIGABRT /
222+
// SIGBUS / SIGILL / SIGFPE to SIG_DFL on the first call (verified
223+
// empirically; see issue #30 for context). guardSignalHandlers
224+
// snapshots Go's handlers now and the deferred closure restores
225+
// them on return — even if chdb_connect panics (a C++ exception
226+
// propagated through purego), the LIFO defer order runs
227+
// recover() first to set err and then the restore, leaving the
228+
// process with Go's stack-growth / panic-recovery handlers
229+
// intact.
230+
//
231+
// On old libchdb builds that don't export the signal-handler
232+
// control API, guardSignalHandlers returns a no-op closure and
233+
// the call site behaves exactly as it did before the issue #30
234+
// fix landed.
235+
defer guardSignalHandlers()()
236+
220237
defer func() {
221238
if r := recover(); r != nil {
222239
err = fmt.Errorf("C++ exception: %v", r)

‎chdb-purego/sigstress_test.go‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package chdbpurego
2+
3+
import (
4+
"os"
5+
"runtime"
6+
"strconv"
7+
"sync"
8+
"sync/atomic"
9+
"testing"
10+
"time"
11+
)
12+
13+
// TestSignalRepro is a deliberately adversarial stress test aimed at
14+
// reproducing the crash from issue #30 locally. Skipped unless STRESS=1.
15+
//
16+
// Strategy: mimic GitHub Actions macos-14 conditions as closely as possible:
17+
// - run with `GOMAXPROCS=3` (the GHA runner has 3 cores allocated);
18+
// - launch many more goroutines than P (heavy oversubscription → Go runtime
19+
// fires SIGURG aggressively for async preemption);
20+
// - recurse with large stack frames so the runtime needs to grow stacks
21+
// (SIGSEGV on the guard page) frequently;
22+
// - drive a chdb query on every iteration so libchdb has live work in
23+
// progress whenever a Go runtime signal lands.
24+
//
25+
// Knobs (env):
26+
//
27+
// STRESS_DURATION = duration string (default 60s)
28+
// STRESS_WORKERS = explicit worker count (default 16 * GOMAXPROCS)
29+
// STRESS_FRAME_KB = per-frame allocation size in KB (default 8)
30+
// STRESS_DEPTH = recursion depth before issuing the query (default 32)
31+
func TestSignalRepro(t *testing.T) {
32+
if os.Getenv("STRESS") != "1" {
33+
t.Skip("set STRESS=1 to run the adversarial repro")
34+
}
35+
36+
duration := envDuration("STRESS_DURATION", 60*time.Second)
37+
workers := envInt("STRESS_WORKERS", 16*runtime.GOMAXPROCS(0))
38+
frameKB := envInt("STRESS_FRAME_KB", 8)
39+
depth := envInt("STRESS_DEPTH", 32)
40+
41+
t.Logf("GOMAXPROCS=%d workers=%d frame=%dKB depth=%d duration=%s",
42+
runtime.GOMAXPROCS(0), workers, frameKB, depth, duration)
43+
44+
conn, err := NewConnectionFromConnString(":memory:")
45+
if err != nil {
46+
t.Fatalf("connect: %v", err)
47+
}
48+
defer conn.Close()
49+
50+
var (
51+
wg sync.WaitGroup
52+
queries atomic.Uint64
53+
failures atomic.Uint64
54+
stop atomic.Bool
55+
)
56+
57+
for i := 0; i < workers; i++ {
58+
wg.Add(1)
59+
go func(id int) {
60+
defer wg.Done()
61+
growAndQuery(conn, depth, frameKB, &queries, &failures, &stop)
62+
}(i)
63+
}
64+
65+
time.Sleep(duration)
66+
stop.Store(true)
67+
wg.Wait()
68+
69+
t.Logf("queries=%d failures=%d (%.0f qps)",
70+
queries.Load(), failures.Load(),
71+
float64(queries.Load())/duration.Seconds())
72+
if failures.Load() != 0 {
73+
t.Fatalf("%d query failures under repro stress", failures.Load())
74+
}
75+
}
76+
77+
// growAndQuery recurses with a large stack frame to force Go stack growth via
78+
// SIGSEGV on the guard page, then drives queries in a tight loop. Each leaf
79+
// goroutine alternates work between Go and libchdb-side C++, maximising the
80+
// chance that a runtime signal lands inside libchdb.
81+
func growAndQuery(conn ChdbConn, depth, frameKB int, queries, failures *atomic.Uint64, stop *atomic.Bool) {
82+
if depth > 0 {
83+
// allocate a sizable local so the Go stack must grow at this frame
84+
frame := make([]byte, frameKB*1024)
85+
_ = frame[0]
86+
growAndQuery(conn, depth-1, frameKB, queries, failures, stop)
87+
return
88+
}
89+
for !stop.Load() {
90+
res, err := conn.Query("SELECT 1 + 1", "CSV")
91+
if err != nil {
92+
failures.Add(1)
93+
continue
94+
}
95+
if res != nil {
96+
res.Free()
97+
}
98+
queries.Add(1)
99+
}
100+
}
101+
102+
func envInt(key string, def int) int {
103+
if v := os.Getenv(key); v != "" {
104+
if n, err := strconv.Atoi(v); err == nil {
105+
return n
106+
}
107+
}
108+
return def
109+
}
110+
111+
func envDuration(key string, def time.Duration) time.Duration {
112+
if v := os.Getenv(key); v != "" {
113+
if d, err := time.ParseDuration(v); err == nil {
114+
return d
115+
}
116+
}
117+
return def
118+
}

0 commit comments

Comments
 (0)