Rails 8.1以降でRubyカバレッジ有効時のセグメンテーション違反を修正
Rails 8.1以降でRubyのCoverageモジュールを有効にした際に発生するセグメンテーション違反を修正しました。この問題は、ERBテンプレートのコンパイル時に使用される負のlineno値とRubyのカバレッジ機能の相互作用によって引き起こされていました。
背景
v4.2.0では、Rails 8.1のERBテンプレートにおけるスタックトレースの行番号のずれを修正するため、class_evalに負のlineno値(-1)を導入しました。しかし、この変更がRubyのCoverageモジュールが有効な環境でセグメンテーション違反を引き起こしていました。
問題の根本原因は、3つの変更が重なったことにあります。Rails 8.1では rails/rails#53731 により、コンパイル済みERB出力にアノテーションコメントが追加され、1行余分に生成されるようになりました。v4.2.0はこの余分な1行を相殺するためlineno = -1を使用しましたが、Ruby 3.4にはevalやclass_evalでの負の行番号がCoverageモジュール実行中にセグメンテーション違反を引き起こすバグ(bugs.ruby-lang.org/issues/19363)が存在します。
SimpleCovなどのカバレッジツールを使用するCI環境では、Rails 8.1 + Ruby 3.4 + view_component 4.2.0の組み合わせでクラッシュが発生していました。
技術的な変更
ファイルベースのテンプレートとインラインテンプレートで異なるアプローチが採用されました。
ファイルベーステンプレートの修正
ViewComponent::Template::Fileクラスでは、カバレッジ実行時にアノテーション行を削除する方式に変更されました。
変更前:
lineno =
if Rails::VERSION::MAJOR >= 8 && Rails::VERSION::MINOR > 0 && details.handler == :erb
-1
else
0
end
変更後:
@strip_annotation_line = false
lineno =
if Rails::VERSION::MAJOR >= 8 && Rails::VERSION::MINOR > 0 && details.handler == :erb
if coverage_running? && ActionView::Base.annotate_rendered_view_with_filenames
@strip_annotation_line = true
0
else
-1
end
else
0
end
カバレッジが実行中で、かつアノテーションが有効な場合、linenoを0に設定し、代わりにcompiled_sourceメソッドでアノテーション行を削除します。
def compiled_source
result = super
result = result.sub(/\A[^\n]*\n/, "") if @strip_annotation_line
result
end
この方式により、負のlineno値を使用せずに正確な行番号を維持できます。
インラインテンプレートの扱い
ViewComponent::Template::Inlineクラスでは特別な処理は不要です。インラインテンプレートはクラス内で定義されるため、常に2行目以降から開始します。1を引いても負の値にならないため、セグメンテーション違反は発生しません。
# Rails 8.1 added a newline to compiled ERB output (rails/rails#53731).
# Subtract 1 to compensate for correct line numbers in stack traces.
# Inline templates start at line 2+, so this won't result in negative values.
lineno =
if Rails::VERSION::MAJOR >= 8 && Rails::VERSION::MINOR > 0 && inline_template.language == :erb
inline_template.lineno - 1
else
inline_template.lineno
end
リグレッションテストの追加
実際のCoverage.startとCoverage.resultを使用した2つのテストが追加されました。
test "file-based templates compile without segfault when coverage is running" do
skip unless Rails::VERSION::MAJOR >= 8 && Rails::VERSION::MINOR > 0
with_new_cache do
with_coverage_running do
ViewComponent::CompileCache.cache.delete(ErbComponent)
render_inline(ErbComponent.new(message: "Foo bar"))
assert_selector("div", text: "Foo bar")
end
end
end
このテストは、カバレッジ実行中にファイルベーステンプレートが正常にコンパイル・レンダリングできることを検証します。インラインテンプレート用の類似テストも追加され、バックトレースの行番号が正しいことも確認します。
設計判断
カバレッジ実行時のみアノテーション行を削除する方式が採用されました。
レビューのフィードバックにより、当初のlineno=1(不正確な行番号になる)アプローチから、アノテーション行を削除する方式に変更されています。この判断により、開発環境や本番環境のユーザーは-1による正確な行番号を得られ、CI/カバレッジ環境のユーザーもアノテーション行の削除により正確な行番号を維持できます。
coverage_running?チェックはFileクラスにのみ適用され、Inlineクラスからは削除されました。これは、インラインテンプレートの行番号が常に正の値であるという特性を活かした判断です。条件分岐を必要な箇所にのみ配置することで、コードの複雑性を最小限に抑えています。
まとめ
本PRは、Rails 8.1のアノテーション機能とRubyのカバレッジ機能の相互作用によるクラッシュを、テンプレートの種類に応じた適切な処理で解決しました。ファイルベーステンプレートではアノテーション行の動的削除、インラインテンプレートでは既存の仕組みの活用という、それぞれの特性に合わせたアプローチにより、すべての環境で正確な行番号とクラッシュ回避の両立を実現しています。