lynx   »   [go: up one dir, main page]

ESLintがセグフォする件を調査していたら、Node.jsにコントリビュートしていた話

はじめに

こんにちは、サポーターズでエンジニアをしている@y_chu5です。

本記事では、当初ESLintのバグと思われていた問題が、実はNode.jsのバグであることが判明し、その修正に至るまでの過程をご紹介します。この体験を通じて得られた知見は、小中規模なプロジェクトのデバッグ手法として参考になるかもしれません。

まず、この問題の発見と初期調査において、VOICEVOXコミュニティのコミュニティサーバーの方々の多大なる貢献があったことを深く感謝申し上げます。彼ら彼女らの綿密な調査と報告がなければ、今回の問題解決には至らなかったと考えています。

問題との出会い

私の所属している VOICEVOX(テキスト読み上げ・歌声合成ソフトウェア)のコミュニティである 「VOICEVOX Communty by Discord」の開発雑談チャンネルで、とても気になるIssueについての話題が挙がってきました。

Windows環境で日本語のファイルをESLintで処理すると、Node.jsがセグメンテーションフォルトを起こす——。最初にこの問題を目にした時、正直なところ「何のことだろう?まあWindowsだしな」という印象でした。

単なる環境の問題ではないかと思っていましたが、チームの有志による調査が進むにつれて、これが Windows上のNode.js特定バージョンでのみ再現する興味深い問題だと分かってきました。

https://github.com/VOICEVOX/voicevox/issues/2379 https://github.com/VOICEVOX/voicevox/issues/2216

私自身、最近Windowsでの開発環境を整えたばかりだったこと、また自分自身がVOICEVOXレビュワーチーム(最近出来ていませんが…)であるということもあり、 「これは面白そうだ」 VOICEVOXの開発を支えたいと本格的な調査に乗り出すことにしました。

最小再現ケースへの調査

調査の過程で非常に役立ったのが、コミュニティから提供された最小再現ケースでした。 具体的には以下のようなコードが提供されていました。

  • ESLint v8系環境
  • eslint-plugin-importを使用した状態で
  • 「あ.js」内にimport句を入れるだけ

このような単純なコードで問題が再現出来ていました。

このコードとESLintのデバッグログ機能を使用して、どの辺りでアプリケーションがクラッシュしているのかを調査しました。

デバッグの本格的なアプローチ

サブモジュールとしてのESLint

より詳細な調査のため、先程の最小再現ケースのgit-submoduleとしてESLintを追加し、ESLint自体をデバッグ実行できるようにしました。 これにより、VS Codeのデバッガーを直接アタッチして、問題の箇所を特定することが容易になりました。

事前の調査でデバッグログが出ており、特定のログ文字列以降にブレークポイントをバシバシ張ることで調査していきます。

より小さい最小再現ケースの作成

上記の調査により、コードが掘りやすくなり、問題の箇所へたどり着きました。

https://github.com/import-js/eslint-plugin-import/blob/668d493dbb456415c3aa12bcbde7917d475393c5/utils/resolve.js#L53

何をやっているかというと

  • Lint対象のファイルでModule.createRequireを行い
  • そのrequire関数でファイル内に記述されているモジュールをrequireする

という処理をしています。 どうも、ここのcreateRequireに渡されたファイルパスに日本語が含まれているとクラッシュしてしまうようです。

この流れを元に、以下の再現ケースを作成しました。

https://github.com/yamachu/node-require-japanese-repro/blob/main/main.js

これでNode.js単体で動作する最小再現ケースが完成し、またESLintもしくはその関連ライブラリの問題でないということが判明しました。

Node.jsバージョンでの調査

二分探索による問題の特定

Node.jsの問題というのがわかりましたが、それではこの挙動はいつから入ってきてしまったのでしょうか? VOICEVOXの .node-version と照らし合わせてみると、 20.12.2 では発生しておらず、22.11.0 以降でその問題が発生したと考えられます。 そのため、その間のバージョンのどこかしらでその挙動になる変更が加わったものだと予想が出来ます。

これらの情報を元に、Node.jsのStableバージョンでの二分探索を実施したところ、22.2.0と22.3.0の間で混入したことが判明します。 しかしこれだけだと数百コミットを洗わないといけません。 より簡単な方法はないだろうかと考えたところ、どうもNode.jsにはNightly buildが存在するではありませんか! Node.jsのNightly buildでは、日付とコミットハッシュを付与し、Nightly buildを提供しているページで各プラットフォーム向けのバイナリを提供しているようでした。

そこで私が過去にVS Code Conferenceで発表したバグ探索の考えと二分探索の考えを元に調査を進めていきます。

https://techblog.cartaholdings.co.jp/entry/vscode_conference_sponsorship

具体的な手順としては以下のとおりです。

  • 22.2.0と22.3.0のリリース日の確認
  • その真ん中のリリース日のNightly buildを試す
  • 動作したらより新しいものを、動作しなかったらより古いものを実験
  • 繰り返す
  • 境界を見つけたらGitHubのcompare機能でコミット単位で確認

上記の方法で原因となったコミットを発見することが出来ました。

この際、先程作っていたNode.js単体で実行可能な最小再現ケースがとても役に立つわけです。*1

そのため、最小再現ケースやテストを書くということはとても大事であるということが、ここでも感じられるわけです。

さて、このコミットを確認してみると、C++で自前実装していたパスのリゾルブ処理が std::filesystem::path を使用する実装に変更されていました。

解決への道のり

システムロケールの影響

VOICEVOXのIssueにとても重要なヒントが隠されていたことに気づきます。

  • Windows 10では文字コードの設定をUI上で変更可能
  • 日本語環境では通常Shift-JIS(CP932)が使用される
  • ベータ機能としてUTF-8に変更出来る
  • UTF-8に変更すると、起こっている問題が再現しなくなる

この情報から、システムロケール及び使用される文字コードの問題であることが明確になりました。

解決策の模索

最初の試みとして、Node.js内部のロケールを変更してみました。

std::locale::global(std::locale("UTF-8"));

これは動作しましたが、グローバルなロケール設定を変更することによる副作用が懸念されました。

他の方法として、例えば型としてUTF-8として表現されているという風に表現すればよいでは?と思い、std::u8stringを代わりに使う方法を試してみました。

std::u8string utf8_path = ここに元の文字列Buffer;
std::filesystem::path path(utf8_path);

これは確かに動作しましたが、pathに\u4e2d\u6587\u76ee\u5f55 みたいな感じの文字列が入っているとクラッシュする問題が発生しました。

そのため、Node.jsのコアモジュールに対してデバッガをアタッチしそもそも渡される文字列はどういう状態になっているのかを調べました。 すると、VS Codeのデバッガには L"ここに文字列" という風に表示されているではありませんか。

https://learn.microsoft.com/ja-jp/cpp/c-language/multibyte-and-wide-characters?view=msvc-170

どうもこれはワイド文字列だったようです。単純なcharとして扱っていたこれは、実はワイド文字列であったということがここで判明します。

それからは簡単で、それを適切に扱ってやればいいのです。

その結果が以下のような変更になりました。

https://github.com/nodejs/node/pull/56696/files#diff-1e725bfa950eda4d4b5c0c00a2bb6be3e5b83d819872a1adf2ef87c658273903

これで他の問題を起こさず、直面している問題を解決することが出来ました。

CI結果の提供

さて、もともとなぜこの問題が起きたかというと、システムロケールの問題依存であるため、変更が他のロケールに問題を及ぼすことが見逃されたためでした。

そのため、今回私は日本語ロケールでのテスト結果なども追加しました。

具体的に言うと、GitHub ActionsのWindowsランナーで以下のコマンドを実行し、再度テストを行った結果をdescriptionや、リンクとして提供しました。

     - name: Change locale ja-JP for testing on SJIS environment
       run: Set-WinSystemLocale -SystemLocale "ja-JP"

これによって、今回の変更が他に影響を及ぼしていないということを示すことが出来ました。

今まで行ってきた調査の内容と、コードの変更、試したけどだめだった内容、加えて自分のリポジトリで試した日本語環境でのCI結果をまとめPull Requestを作成しました。 レビューでのやり取りを経て、無事mainに取り込まれたわけです。

https://github.com/nodejs/node/pull/56696#issuecomment-2613826343

おわりに

このバグ修正を通じて、小中規模なプロジェクトでのデバッグ手法について多くを学ぶことができました。 特に、再現ケースを最小化することの重要性と、適切なデバッグツールの使用方法を身につけることの大切さを実感しました。

VS CodeなどエディタやIDEなどの使い方を習得することは、このような調査において非常に役に立ちます。ツールの使い方を学ぶことで、自然とデバッグの考え方も身についていくことでしょう。

そして、コミュニティなどにおいて、現時点分かっていること、試したことの共有がとても重要であることを再度実感しました。 これからも何か問題を見つけた時、情報をどんどん開示して問題の解決に役に立てたいなと感じました。

あらためてVOICEVOXに関わる全ての方々に感謝申し上げます。

*1:最小ケースがあるため、git bisectで自動化も可能でしたが、Node.js自体のビルドに時間がかかること、Headerに変更が加わった場合の再ビルドなどのコストを考え、今回は手動で行っています。

Лучший частный хостинг