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.

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