Skip to content

[bug] SAX::ParserContext.io segfaults after GC compaction — raw VALUE handed to xmlCreateIOParserCtxt #3669

Description

@jeremy

The second of the two IO sites listed at the end of #3667, filed on that PR's offer. Same
mechanism as the XML::Reader.from_io issue I've opened alongside this — if you'd rather
track them as one, close this into that one; I've split them only because their reach differs
and I didn't want the narrower one to inflate the other.

Please describe the bug

XML::SAX::ParserContext.io(io) hands libxml2 the IO's VALUE cast to void *. libxml2
keeps that pointer in the parser context and hands it back to noko_io_read on every read
during a later parse_with, where it's cast back to a VALUE and sent #read.

Line 106 does rb_iv_set(rb_context, "@input", rb_io), which keeps the IO alive — but
xml_sax_parser_context_type declares only .dfree, no .dmark and no compact callback, and
the ivar is marked movably. Nothing keeps the IO in place. Compaction between
ParserContext.io and parse_with leaves libxml2 holding a stale address.

Help us reproduce what you're seeing

The context is built in one method and parsed in another, which is what leaves the IO
reachable only through @input.

require "nokogiri"
require "objspace"

File.write("/tmp/doc.xml", "<root>#{"<a/>" * 5000}</root>")

def addr(o) = ObjectSpace.dump(o)[/"address":"([^"]+)"/, 1]

class Handler < Nokogiri::XML::SAX::Document
  attr_reader :n
  def initialize = @n = 0
  def start_element(_name, _attrs = []) = @n += 1
end

class Streamer
  attr_reader :ctx
  def initialize(path)
    io = File.open(path)      # a method local, pinned only while THIS frame is alive
    @ctx = Nokogiri::XML::SAX::ParserContext.io(io)
  end                         # frame pops here: the IO now lives only in the context's @input
  def io = @ctx.instance_variable_get(:@input)
  def count
    handler = Handler.new
    @ctx.parse_with(Nokogiri::XML::SAX::Parser.new(handler))
    handler.n
  end
end

def main
  streamer = Streamer.new("/tmp/doc.xml")

  before = addr(streamer.io)
  GC.verify_compaction_references(expand_heap: true, toward: :empty)
  warn "IO relocated: #{before} -> #{addr(streamer.io)}"

  # The allocations alone leave the stale read *silently* wrong rather than fatal, because
  # noko_io_read's rb_rescue swallows the NoMethodError and returns -1. The GC.start is what
  # turns it into a crash.
  20.times { 2_000.times { +("Z" * 10) } }
  GC.start

  puts "elements=#{streamer.count} expected=5001"
end
main

Actual:

IO relocated: 0x12029a010 -> 0x1203a2f98
min_sax.rb:24: [BUG] Segmentation fault at 0x0000000000000010
ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin23]

Expected, and what you get with the GC.verify_compaction_references line removed (I ran that
as a control): elements=5001 expected=5001.

5/5 SIGSEGV with the compaction, 5/5 correct without. Document size is irrelevant — 100
bytes and 2.7 MB both fail 3/3.

Scope — this one is narrower than the reader site

shape result
ParserContext.io(io) … compaction … parse_with later FAIL 5/5
SAX::Parser#parse(io) / #parse_io(io) one-shot pass
HTML4::Document.parse(io) (html4_document.c:51, same cast) pass
XML::Document.parse(io) (xml_document.c:388, same cast) pass

SAX::Parser#parse_io builds the context and calls parse_with inside one Ruby frame, so the
io argument is a local in a live frame — which does pin (checked separately: method locals,
block locals and locals captured by a lambda or binding are all pinned 3/3 on ruby 4.0.6 and
3.4.10). And during a read noko_io_read holds the IO in a C-stack VALUE rb_args[2] that
conservative machine-stack scanning pins independently; I confirmed that directly by driving
GC.verify_compaction_references from inside a custom IO's own #read, the only place a Ruby
callback runs during a one-shot parse — the IO did not relocate, on every run, for all
three passing shapes.

What is unpinned is an object reachable only through an ivar once the constructing frame has
popped, which is what rb_iv_set(rb_context, "@input", rb_io) leaves behind. That relocates
3/3.

So this needs the two-step form: hold the context, compact, then parse. The docs already call
that "discouraged" (lib/nokogiri/xml/sax/parser_context.rb), which is why I'd rate it below
the Reader.from_io one — Reader.new(io) is the documented constructor and its reads are
idiomatically deferred, so it reaches the same window through ordinary code.

What an operator would actually have to do — and the limit of what I have shown

The relocation above is forced by GC.verify_compaction_references, which is a debug API no
production app calls, so this is the first thing worth being straight about.

I could not relocate the IO with anything else. With fragmentation in the subject's own
size pool — 800k retained Objects in the same slot-40 pool, freed afterwards to leave early
holes, with the subjects allocated into late pages:

trigger IO object relocated same-run String witnesses
GC.compact ×5 0/200 200/200 (4.0.6)
GC.auto_compact = true + GC.start ×5 0/200 200/200 (4.0.6)
GC.verify_compaction_references(expand_heap: true, toward: :empty) 200/200 200/200

Measured on ruby 4.0.6 and 3.4.10, arm64-darwin, same numbers on both. The String column is
there to show the compactor was working and had holes to work with; it simply does not choose
to relocate these T_DATA/T_OBJECT slots. (They are movable in principle — verify_compaction_references
moves them 200/200 — so this looks like which pages plain compaction selects, not an
immovability rule.)

So the honest position: the stored pointer is unconditionally stale-able and the object is
provably movable, but I have not demonstrated a path to it that doesn't go through the debug
API.
Treat this as a latent correctness bug rather than something firing in production today.
If it's easier to see it in your own infra, test/test_compaction.rb already drives
gc_verify_compaction_references — that is exactly the instrument used here.

Expected behavior

A parser context built from an IO keeps working after the GC relocates the IO.

Environment

nokogiri 1.19.4 (arm64-darwin, precompiled) — packaged libxml2 2.13.9, libxslt 1.1.43
ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin23]

Present on main as of 04a4c29: ext/nokogiri/xml_sax_parser_context.c:90-93 still passes
(void *)rb_io to xmlCreateIOParserCtxt, and xml_sax_parser_context_type (line 24) still
declares only .dfree.

Cause

ext/nokogiri/xml_sax_parser_context.c:88-93:

  xmlParserCtxtPtr c_context =
    xmlCreateIOParserCtxt(NULL, NULL,
                          (xmlInputReadCallback)noko_io_read,
                          (xmlInputCloseCallback)noko_io_close,
                          (void *)rb_io, XML_CHAR_ENCODING_NONE);
  ...
  rb_iv_set(rb_context, "@input", rb_io);

read back in ext/nokogiri/nokogiri.c:72 (VALUE rb_io = (VALUE)io;), with

static const rb_data_type_t xml_sax_parser_context_type = {
  .wrap_struct_name = "xmlParserCtxt",
  .function = { .dfree = xml_sax_parser_context_type_free },
  .flags = RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED,
};

@input keeps it reachable, so this is a use-after-move, not a use-after-free.
html4_document.c:51 and xml_document.c:388 make the same cast and are fine, because the
pointer never outlives the call — the distinction is persistence, not the cast.

A regression-test warning that cost me a run: because noko_io_read wraps the call in
rb_rescue, a vacated slot refilled by a live object that just doesn't answer #read
produces a swallowed NoMethodError, a -1 return, and a silently empty parse rather
than a crash. Churning after the compaction without forcing a sweep gave me 3/3 "clean" on
a build that is fully broken. Asserting "didn't segfault" isn't enough; assert the element
count.

Possible fix

Same design question as the reader site and as raised in #3667: the VALUE lives inside a
libxml2 struct with no slot we own, so it needs either a malloc'd holder threaded through
noko_io_read (like the nokogiriTuplePtr in xml_document.c) or pinning via rb_gc_mark
from a dmark on xml_sax_parser_context_type. Whichever you pick for Reader, the same
shape applies here — they should probably land together. I haven't built either; happy to
send the PR once you've said which, with a regression test in test/test_compaction.rb.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions