Psych::Emitter breaks after GC compaction: raw VALUE stored in libyaml output handler
まだ誰も着手していません。
評価
調査の方向性
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
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートなし
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
ruby/psych のほかの issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
-
Documentation
難易度 1/5 1時間未満 初心者へのやさしさ 72/100
-
Psych.dump raises Encoding::CompatibilityError for UTF-16/UTF-32 strings対応中かも @Halvanhelv が 30 日前に担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 55/100
-
難易度 4/5 3〜5日 初心者へのやさしさ 68/100
-
難易度 5/5 1週間以上 初心者へのやさしさ 35/100
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 62/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
Homebrew/homebrew-cask#293134 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
notch8/iiif_print#430 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 65/100
メンテナーはふだん 1 日以内に返信
-
ActionText::Content#to_markdown: <br> in a table cell and empty <tr> produce malformed Markdown tables対応中かも @azmi2409 が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
メンテナーはふだん 1 日以内に返信