Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

Psych::Emitter breaks after GC compaction: raw VALUE stored in libyaml output handler

オープン
#811 コメント 1 件 リアクション 0 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
52/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
静か
技術スタック
c, ruby
領域
backend

調査の方向性

ext/psych/psych_emitter.c:27 と :97 から始め、次に psych_emitter_type と既存の Psych emitter テストを調べます。影響を受ける Ruby および Psych のバージョンで、提供されている GC.verify_compaction_references の再現を実行し、ライフタイム処理を修正する前後の挙動を比較します。長期間存続する Psych::Emitter インスタンスがコンパクション後も正常に書き込みを続け、対象ケースをカバーするリグレッションテストがあることを完了条件とします。

索引モデルが issue の本文から書いたものです。

説明

Summary

Psych::Emitter stores its own VALUE in the libyaml emitter as a raw pointer and reads it back on every write. Nothing keeps that object in place, so if GC compaction relocates the emitter between initialize and the first emit, libyaml hands back a stale address and the write handler operates on whatever object now occupies it.

Reproduction

require "psych"
require "stringio"

io = StringIO.new
# Parked off-stack on purpose: a local variable is conservatively pinned by the
# machine-stack scan, which masks the bug.
$holder = [Psych::Emitter.new(io)]

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

e = $holder[0]
e.start_stream(Psych::Nodes::Stream::UTF8)
e.start_document([], [], true)
e.scalar("hello", nil, nil, true, false, Psych::Nodes::Scalar::ANY)
e.end_document(true)
e.end_stream

puts io.string

Actual:

psych_emitter.rb:22:in 'Psych::Emitter#end_document': undefined method 'write' for nil (NoMethodError)

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

hello

Reproduces every time on psych 5.4.0 / ruby 4.0.6 (arm64-darwin23). The code is unchanged on master.

Cause

ext/psych/psych_emitter.c:97 hands libyaml the emitter's own VALUE:

yaml_emitter_set_output(emitter, writer, (void *)self);

and ext/psych/psych_emitter.c:27 casts it straight back on every write:

static int writer(void *ctx, unsigned char *buffer, size_t size)
{
    VALUE self = (VALUE)ctx, io = rb_attr_get(self, id_io);

psych_emitter_type has no mark function at all:

static const rb_data_type_t psych_emitter_type = {
    "Psych/emitter",
    {0, dealloc, 0,},
    ...

so nothing pins the Emitter and nothing relocates the copy of its address held inside libyaml. After compaction, self refers to some unrelated object, rb_attr_get(self, id_io) finds no @io and returns nil, and the nil.write above follows. Depending on what lands at that address the failure mode could also be a wrong result or a crash rather than a clean NoMethodError.

Scope

Narrow. Psych.dump and friends are unaffected because they construct and consume the emitter inside a single call, so it is stack-live and conservatively pinned throughout. The parser is fine too — yaml_parser_set_input(parser, io_reader, (void *)yaml) passes a stack-live local. Only a long-lived Psych::Emitter, i.e. the streaming-emission API, is exposed.

Ordinary GC.compact does not reliably reproduce it: I ran 20 rounds with allocation churn and the emitter simply never got relocated. It needs the object to actually move, which verify_compaction_references forces and which GC.auto_compact = true would make possible in the wild.

Possible fixes

Either of the two idioms already used elsewhere in the ecosystem:

Pin it, as openssl does for the VALUEs it stores in OpenSSL's ex_data (ossl_sslctx_mark calls rb_gc_mark on the value it fetched back out):

static void mark_emitter(void *ptr)
{
    yaml_emitter_t *emitter = ptr;
    if (emitter->write_handler_data) {
        rb_gc_mark((VALUE)emitter->write_handler_data);
    }
}

Or relocate it, as nokogiri does for the VALUEs it stores in libxml2's _private, with a dmark using rb_gc_mark_movable plus:

static void compact_emitter(void *ptr)
{
    yaml_emitter_t *emitter = ptr;
    if (emitter->write_handler_data) {
        emitter->write_handler_data =
            (void *)rb_gc_location((VALUE)emitter->write_handler_data);
    }
}

Happy to send a PR with whichever you prefer, plus a regression test along the lines of the reproduction above.

Context

Found while auditing this bug class after fixing the equivalent problem in sqlite3-ruby (sparklemotion/sqlite3-ruby#723, where trace and authorizer failed with the identical undefined method 'call' for nil signature for exactly the same reason).

主要言語
Ruby
スター
597
フォーク
223
平均マージ
6時間 8分
マージ済み PR(30日)
2

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

ruby/psych のほかの issue

ruby/psych の issue をすべて見る

似ている issue

Ruby の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。