概要
Windows の日本語環境では,パス名に非 ASCII 文字が含まれるとき,ディレクトリー監視を行うライブラリー listen がエラーを吐くことがある,という話。
listen は gem の形で配布されているライブラリーで,指定したディレクトリー以下を監視し,ファイルの変更,追加,削除があったらそのつど指定の処理を実行してくれる。
主要な OS で動作する。非常によく使われているようで,これに依存する gem も多い。
算譜
以下のようなファイルが存在するとする。
C:\でれくとり\ふぁいる.txt
問題の再現コードは以下のとおり。
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 31J1 の文字列を結合しようとして失敗する例のアレだ2。
例外は listen の内部で起こっている。
ユーザーが多く,継続的にメンテナンスされている印象の listen だが,どうしたことだろう。
原因
例外が発生した箇所は ここ。
抜粋すると
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
を書き換えることになる。
いわゆるモンキーパッチ3というやつですな。
いずれの場合も,使われている箇所が内部なので,refinement のような生ぬるい手段は取れない。メソッドをもろに上書きしてやるのだ。ふふ。
どうせ今回作っていたのはライブラリーとかではなく小さなアプリケーションなので,書き換えたところで「思わぬ副作用」を心配する必要もない。
で,どういうわけかその時の私は sys_path
を書き換えるという発想が無く,当然のように File.join
を書き換えようとした。
File.join
のエイリアスを作っておき,新しい File.join
は引数の文字コードを揃えてから古いほうに丸投げしてやればいいだろう,と。
特異メソッドのエイリアスを作る方法がなかなか分からなかったけれど,特異クラス内でふつうに alias
すればいいんだった。
つまり,以下のようなコードを追加した。
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 を立てても「再現しない」「直せない」「直せたのかよく分からん」と言われることがある(それらより放置されることのほうが多いかも?)。