🖍️

lint-staged (v15.2.1未満) + rubocopでrubocopのServer Modeが有効だと終了しなくなる

2023/05/03に公開

私が開発しているRailsプロジェクトで起きた話を紹介します。
このプロジェクトではhusky + lint-stagedを使い、RubyファイルはRubocopを使ってフォーマットするルールになっていました。

Rubocop v1.31からServer Modeが導入されました。これはRubocopを起動したままにしておくことでそれ以降の起動を高速化するものです。Emacs daemonのようなものをイメージするとわかりやすいかもしれません。
Rubocopサーバーは rubocop --start-server で明示的に起動することもできますし、 rubocop --server をautocorrect時などに付けておくことで起動してないときだけ起動してくれるようになります。

このプロジェクトではgit precommitごとにrubocopをかけるように設定されていたのですがrubocop起動だけで数秒かかっていました。そんな状況だったのでこれはよさそうとServer Modeが使えるバージョンに引き上げました。

ところが、Rubocopサーバーが起動中のときにだけ git precommit フックがいつまでも終了しないという問題が発生しました。

(git commitがrubocopでずっと止まってしまう)

❯ git commit -m 'implement'
✔ Preparing lint-staged...
❯ Running tasks for staged files...
  ❯ .lintstagedrc — 4 files
    ❯ * — 4 files
      ⠦ bundle exec rubocop --server --autocorrect --force-exclusion
◼ Applying modifications from tasks...
◼ Cleaning up temporary files...

Rubocopサーバーが起動してないときにはちゃんとコミットできることやシェルで直接rubocopを実行すると正常に終了することから、なんらかの理由でlint-stagedからの起動だとrubocopが止まってしまうことが予想されました。

サブプロセスの標準入力が閉じられていなかったが原因だった

今回の問題は再現性があったため、容易に原因を絞り込んでいくことができました。

rubocopはサーバーが有効なときにはサーバーに向けてTCPソケットでコマンドを送信するように実装されていました。

def run
  ensure_server!
  Cache.status_path.delete if Cache.status_path.file?
  send_request(
    command: 'exec',
    args: ARGV.dup,
    body: $stdin.tty? ? '' : $stdin.read
  )
  # ↑ここで固まる
  warn stderr unless stderr.empty?
  status
end

https://github.com/rubocop/rubocop/blob/v1.50.2/lib/rubocop/server/client_command/exec.rb#L21-L25

ところがlint-stagedの外部プロセスの起動のやり方だと外部プロセスの標準入力が閉じられておらず、そのため $stdin.read で標準入力を無限に読み終わるまで待ってしまっていました。

※lint-stagedは外部プロセスの起動にはexecaを使っています。execaにはinputオプションがあり、これを指定することで標準入力に渡す文字列を指定できます (そこまで読み切ったらEOFとなる)。ですがこのオプションを利用していないためlint-stagedから起動された外部プロセスの標準入力は閉じられていませんでした。

暫定対応

コミットが固まってしまう原因はわかりましたが、lint-stagedのオプションや引数では制御できない問題でした。幸いなことにlint-stagedは外部プロセスの起動コマンドをシェルで実行するオプションがあったため、rubocopへの標準入力を閉じるコマンドにきりかえてみたところサーバーモードが有効でもコミットが固まらなくなりました。

.husky/pre-commit

before

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged

after

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged --shell

.lintstagedrc

before

{
  "*.rb": "bundle exec rubocop --server --autocorrect --force-exclusion"
}

after

{
  "*.rb": "true | bundle exec rubocop --server --autocorrect --force-exclusion"
}

また、今回調べたことを lint-stagedのIssueとしても報告しました。

https://github.com/okonet/lint-staged/issues/1293

まとめ

  • lint-stagedでサーバー起動済のrubocopを起動すると閉じられてない標準入力を読み続けてしまうためrubocopが終了しなくなる
  • サーバー未起動中のときは通らない処理なので時間はかかるがrubocopは正常に終了する

という不思議な挙動にでくわした話でした。

Discussion