listen が Encoding::CompatibilityError で死ぬ（Windows はつらいよ）


# 概要

Windows の日本語環境では，パス名に非 ASCII 文字が含まれるとき，ディレクトリー監視を行うライブラリー listen がエラーを吐くことがある，という話。

[listen](https://rubygems.org/gems/listen) は gem の形で配布されているライブラリーで，指定したディレクトリー以下を監視し，ファイルの変更，追加，削除があったらそのつど指定の処理を実行してくれる。

主要な OS で動作する。非常によく使われているようで，これに依存する gem も多い。

# 算譜

以下のようなファイルが存在するとする。

```
C:\でれくとり\ふぁいる.txt
```

問題の再現コードは以下のとおり。

```rb
gem "listen", "3.2.0"
require "listen"

target_dir = "C:/でれくとり"

listener = Listen.to(target_dir) do |modified, added, removed|
  puts "[modified]", modified
  puts "[added]", added
  puts "[removed]", removed
  puts
end

listener.start
sleep
```

`C:\でれくとり` というディレクトリーを監視して，ファイルの変更，追加，削除があったらそのパスを画面に表示するだけのもの。

`Listen.to` メソッドに監視対象のディレクトリーのパスを渡している。
短い時間サイクルごとに，（そのサイクル内で）ファイルの変更，追加，削除があったら，ブロックを実行してくれる。
時間サイクル内で複数のファイルが追加されたり，といったこともあるので，`modified`, `added`, `removed` はすべて配列である。該当ファイルが無ければ空配列になる。

オプションで動作を細かく指定することもできる。

`Listen.to` を実行しただけでは監視は始まらない。`start` メソッドで監視が始まる。

`sleep`（引数が無いので永遠に眠る）を置くことによって，Ctrl+C で止めるまでずっと監視が継続するようになる。
`sleep` が無いと一瞬でスクリプトが終了してしまう。

# 現象

さて，前節のスクリプトを Windows の日本語環境で起動すると，以下のような例外が発生する。

```
C:/Ruby26-x64/lib/ruby/gems/2.6.0/gems/listen-3.2.0/lib/listen/record/entry.rb:38:in `join': incompatible character encodings: UTF-8 and Windows-31J (Encoding::CompatibilityError)
```

つまり，UTF-8 と Windows 31J[^cp932] の文字列を結合しようとして失敗する**例のアレ**だ[^are]。

[^are]: Windows Rubyist にはおなじみのアレである。

[^cp932]: Windows 31J は CP932 とか Codepage 32 とも呼ばれる。平たく言えば「Windows のシフト JIS」のこと。

例外は listen の内部で起こっている。
ユーザーが多く，継続的にメンテナンスされている印象の listen だが，どうしたことだろう。

# 原因

例外が発生した箇所は [ここ](https://github.com/guard/listen/blob/v3.2.0/lib/listen/record/entry.rb#L38)。

抜粋すると

```rb
def sys_path
  # Use full path in case someone uses chdir
  ::File.join(*[@root, @relative, @name].compact)
end
```

となっている。この `File.join` で例外が発生した。

今の場合，`@root` が UTF-8 で，`@name` が Windows 31J になっていた。
これを `File.join` で繋ごうとしたので「できるかっ！」と怒られたんである。

`@root` が　UTF-8　だったのは，おそらく `Listen.to` に UTF-8 の文字列を渡したから。
`@name` のほうは listen が自分でディレクトリーを探索して拾ってきたわけだけど，場合によって Windows 31J だったり UTF-8 だったりするようだ。

そういう，文字コードがバラバラな可能性を考えずに繋ごうとしているわけね。

# 対策

とりあえず，応急処置としては，`sys_path` か `File.join` を書き換えることになる。
いわゆるモンキーパッチ[^mp]というやつですな。

[^mp]: 今年（2019 年）の春に亡くなった [著名漫画家](https://ja.wikipedia.org/wiki/%E3%83%A2%E3%83%B3%E3%82%AD%E3%83%BC%E3%83%BB%E3%83%91%E3%83%B3%E3%83%81) ではない。猿の股引きでもない。

いずれの場合も，使われている箇所が内部なので，refinement のような生ぬるい手段は取れない。メソッドをもろに上書きしてやるのだ。ふふ。
どうせ今回作っていたのはライブラリーとかではなく小さなアプリケーションなので，書き換えたところで「思わぬ副作用」を心配する必要もない。

で，どういうわけかその時の私は `sys_path` を書き換えるという発想が無く，当然のように `File.join` を書き換えようとした。

`File.join` のエイリアスを作っておき，新しい `File.join` は引数の文字コードを揃えてから古いほうに丸投げしてやればいいだろう，と。

特異メソッドのエイリアスを作る方法がなかなか分からなかったけれど，特異クラス内でふつうに `alias` すればいいんだった。

つまり，以下のようなコードを追加した。

```rb
class << File
  alias _join join

  def join(*args)
    _join(*args.map{ |x| x.encode(Encoding::UTF_8) })
  end
end
```

引数を全部 UTF-8 に変換している。

これで解決した。エラーは出なくなった。

# 結語

Windows の日本語環境では，誰かの作ったライブラリー内で Encoding::CompatibilityError が出て死ぬ，ということがときどき起こる。たいがい UTF-8 と Windows 31J の文字列を結合しようとしたのが原因だ。

そういうときは，例外の発生箇所を調べてそこに至る各文字列の文字コードを調べていくことになる。めんどくさい。

たぶんライブラリー開発者のほとんどは macOS か Linux なのだ。そこでは非 UTF-8 文字列が不意に紛れ込むことが起こらないのだろう。
実際，文字コード絡みの issue を立てても「再現しない」「直せない」「直せたのかよく分からん」と言われることがある（それらより放置されることのほうが多いかも？）。