Skip to content

set_notice_receiver/processor: raw VALUE handed to libpq breaks under GC compaction (SEGV) #734

Description

@jeremy

Summary

PG::Connection#set_notice_receiver and #set_notice_processor hand libpq the raw VALUE of the connection object as callback user-data. libpq stores it inside the PGconn struct — libc-malloc'd memory the Ruby GC neither scans nor updates — and the proxies cast it straight back on every server notice. Nothing pins or relocates the connection object, so once GC compaction moves it, the next notice dereferences a dead address: NoMethodError, TypeError, or a segfault depending on what now occupies it.

Reproduction

require "pg"

$got = []
# Parked off-stack on purpose: a local variable is conservatively pinned by the
# machine-stack scan, which masks the bug.
$holder = [PG.connect(host: "127.0.0.1", port: 5432, user: "postgres", password: "…", dbname: "postgres")]
$holder[0].set_notice_receiver { |res| $got << res.error_message }

GC.verify_compaction_references(expand_heap: true, toward: :empty)

$holder[0].exec("DO $$ BEGIN RAISE NOTICE 'hello-from-server'; END $$;")
p $got

Actual, 3/3 runs (mix of both outcomes across runs):

pg_notice.rb:14: [BUG] Segmentation fault at 0x0000000000000010
pg_notice.rb:14:in 'PG::Connection#exec': undefined method 'to_s' for an instance of Prism::… (NoMethodError)

Expected, and what you get with the GC.verify_compaction_references line removed (I ran that as a control, 3/3):

["NOTICE:  hello-from-server\n"]

set_notice_processor fails identically. Verified against PostgreSQL 16, on both the precompiled pg-1.6.3-arm64-darwin gem and a from-source build of 1.6.3, so it is not a packaging artifact.

Cause

ext/pg_connection.c:2994 and :3055:

PQsetNoticeReceiver(this->pgconn, gvl_notice_receiver_proxy, (void *)self);
PQsetNoticeProcessor(this->pgconn, gvl_notice_processor_proxy, (void *)self);

read back at :2933 and :3011:

void
notice_receiver_proxy(void *arg, const PGresult *pgresult)
{
	VALUE self = (VALUE)arg;
	t_pg_connection *this = pg_get_connection( self );

pgconn_gc_mark / pgconn_gc_compact (:171 / :185) already do the right thing for all eight VALUE fields of t_pg_connectionrb_gc_mark_movable plus pg_gc_location. What's missing is the connection object itself: t_pg_connection has no self back-reference, so the copy of its address sitting inside libpq is never updated. pg_connection_type carries only RUBY_TYPED_WB_PROTECTED, so the object is fully movable.

These two are the only libpq entry points pg feeds a void * to (PQtrace takes a FILE *), which matches what I found testing the rest of the surface — see below.

Ordinary GC.compact and GC.auto_compact = true did not reproduce it in the heap layouts I tried; the connection simply didn't get relocated. So this is a latent soundness bug that fires whenever the object does move, rather than something guaranteed to bite under default settings today.

Proposed fix

Use the pattern pg already uses for t_tmir.self (ext/pg_type_map_in_ruby.c:19, relocated in pg_tmir_compact at :34 via pg_gc_location) and t_pg_coder.coder_obj: pass the stable xmalloc'd struct pointer to libpq and carry a relocatable self back-reference.

/* pg.h, in t_pg_connection */
VALUE self;                       /* back-ref; relocated on compaction */

/* pgconn_s_allocate */
this->self = self;

/* pgconn_gc_compact — compact only, not gc_mark: it's a self-reference */
pg_gc_location( this->self );

/* the proxies */
t_pg_connection *this = (t_pg_connection *)arg;
VALUE self = this->self;          /* the receiver proxy needs self for pg_new_result_autoclear */

/* registration */
PQsetNoticeReceiver(this->pgconn, gvl_notice_receiver_proxy, (void *)this);
PQsetNoticeProcessor(this->pgconn, gvl_notice_processor_proxy, (void *)this);

Pinning with rb_gc_mark(this->self) in pgconn_gc_mark would also close it, at the cost of immobilizing every connection for its lifetime — the relocating version above matches what the rest of the extension already does.

Rest of the surface: tested, no other failures

Exercised after forced compaction with objects parked off-stack, all matching their controls: exec, exec_params, PG::Tuple, PG::Result#values, BasicTypeMapForResults/ForQueries, TypeMapByColumn/ByOid/ByClass, a Ruby-subclassed TypeMapInRuby, TextDecoder::Integer, CopyRow + copy_data/put_copy_data, escape_string, and LISTEN/NOTIFY + wait_for_notify.

Two things that look like this bug but aren't: the xmalloc'd OID/column caches (pg_type_map_by_oid.c:66, pg_type_map_by_column.c:41,273) store t_pg_coder * — TypedData data pointers, which compaction never moves — not VALUEs; and the apparent rb_gc_mark_movable/pg_gc_location count skew is just the compact-only helpers for the self-reference fields above.

Environment

pg 1.6.3 (both arm64-darwin precompiled and --platform=ruby source build)
PostgreSQL 16 (docker postgres:16)
ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin23]

Context

Found while auditing this bug class across C extensions after fixing the equivalent problem in sqlite3-ruby (sparklemotion/sqlite3-ruby#723). Same root cause confirmed and reported in psych (ruby/psych#811), nokogiri (sparklemotion/nokogiri#3665), and fiddle (ruby/fiddle#211). mysql2 is not affected — the only thing it hands libmysqlclient is its xmalloc'd wrapper struct, not a VALUE.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions