Skip to content

Add native crash handler: die loudly on C-thread fatal signals#889

Merged
benben merged 2 commits into
mainfrom
ben/native-crash-handler
Jul 3, 2026
Merged

Add native crash handler: die loudly on C-thread fatal signals#889
benben merged 2 commits into
mainfrom
ben/native-crash-handler

Conversation

@benben

@benben benben commented Jul 3, 2026

Copy link
Copy Markdown
Member

Why

We found production worker pods pinned for 17–33 hours by JDBC catalog queries (getColumns() / getPrimaryKeys()) that never finished. Native stack captures (gdb via ephemeral debug container) showed the mechanism:

  1. a DuckDB-created thread segfaulted inside postgres_scanner (PostgresCatalogSet::TryLoadEntries, during duckdb_indexes() init);
  2. the Go runtime intercepted the C-thread SIGSEGV (runtime.badsignal → raisebadsignal) and the thread wedged there instead of the process dying — no log line anywhere;
  3. it died holding the catalog-set mutex, a TaskScheduler thread blocked on that mutex forever, and the query's main thread busy-spun in Executor::ExecuteTask at exactly 1 core;
  4. the connection stayed active forever, which also pinned control-plane pods in their (by-design unbounded) drain across multiple deploy generations.

Killing the process is strictly better than this: the CP's existing health-check/reap machinery handles worker death cleanly within seconds (verified live — when gdb released the pending signal the worker died, the session errored, and the CP finished draining immediately).

What

internal/crashhandler (linked into all three binaries) installs a native handler for SIGSEGV/SIGBUS/SIGILL/SIGFPE/SIGABRT from a C constructor, i.e. before the Go runtime registers its own handlers — so the runtime saves it as the pre-existing handler and forwards non-Go faults to it (runtime.sigfwdgo). The handler is async-signal-safe: it writes a grep-able marker (duckgres: fatal native signal) plus a backtrace_symbols_fd native backtrace to stderr, restores the default disposition, and re-raises — the process dies with the original signal.

Faults raised by Go code are untouched: the runtime doesn't forward those, so ordinary Go panics (nil deref etc.) behave exactly as before.

Tests

TDD package tests (internal/crashhandler) re-exec the test binary and assert on the child's fate:

  • SIGSEGV on a C-created thread (the incident class) → dies by signal, marker + backtrace on stderr;
  • SIGSEGV inside a cgo call on a Go thread → same;
  • Go nil dereference → still a normal Go panic, no native report.

Not assertable in the e2e harness — no SQL statement deterministically segfaults a worker; documented in tests/e2e-mw-dev/README.md ("deliberately not covered"). Verified green on darwin/arm64; Linux runs in CI.

Follow-ups (separate)

  • Alert on the stderr marker in the log pipeline.
  • Catalog-query statement timeout + retire-worker-on-timeout.
  • Upstream DuckDB/postgres_scanner report with the captured stacks.

🤖 Generated with Claude Code

A SIGSEGV on a thread the Go runtime does not own (e.g. a DuckDB
TaskScheduler thread crashing inside an extension) is routed through the
runtime's badsignal path and can leave the process alive but wedged: the
crashed thread never releases its locks, and every thread that later
touches them blocks forever. In production this turned a
postgres_scanner segfault during a JDBC catalog scan into a query that
ran 30+ hours, pinning a 15-CPU worker pod and blocking control-plane
drains, with no log line anywhere.

internal/crashhandler installs (via C constructor, before the Go runtime
registers its handlers, so runtime.sigfwdgo chains to it) a native
handler for SIGSEGV/SIGBUS/SIGILL/SIGFPE/SIGABRT that writes a
grep-able marker + native backtrace to stderr and re-raises with the
default disposition, so the process dies and the control plane's
existing crash-reap machinery takes over. Faults raised by Go code are
not forwarded by the runtime and keep producing ordinary Go panics.

Package tests re-exec the test binary and assert death-by-signal plus
the stderr marker for a C-thread segfault and a cgo-call segfault, and
that a Go nil dereference still panics normally. Not assertable in the
e2e harness (no SQL deterministically segfaults a worker) — noted in
tests/e2e-mw-dev/README.md.
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

Test Impact Plan

Deterministic summary of how this PR changes tests, CI runners, and coverage-risk signals.

Summary

Area Added Changed Deleted
Test files 1 1 0
E2E/journey files 0 1 0
Workflow files 0 0 0

Signals

  • Test cases: +5 / -0
  • Assertions: +14 / -0
  • Skips or known failures added: 0
  • Workflow continue-on-error added: 0
  • Workflow path filters added: 0
  • Test commands removed from justfile: 0
  • E2E/journey retry lines added: 0

Coverage risk: needs review

Warnings

  • E2E or journey files changed (needs review)
    • tests/e2e-mw-dev/README.md

glibc's backtrace() through a signal frame can yield as few as two
frames on linux/amd64, which failed the test's frame-count assertion in
CI (the handler itself worked: marker + death by signal). Assert one
frame address plus the fault-address line instead, matching both the
glibc and macOS backtrace_symbols_fd formats.

Make the shallow-trace case actionable: the report line now includes
the faulting PC (from the signal ucontext on linux/darwin amd64+arm64)
and si_addr, printed with an async-signal-safe hex writer, so a crash
site can be resolved with addr2line even without deep unwind.
@benben benben merged commit 92c607a into main Jul 3, 2026
28 checks passed
@benben benben deleted the ben/native-crash-handler branch July 3, 2026 11:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant