Help us understand the problem. What is going on with this article?

listen が Encoding::CompatibilityError で死ぬ(Windows はつらいよ)

概要

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_pathFile.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 を立てても「再現しない」「直せない」「直せたのかよく分からん」と言われることがある(それらより放置されることのほうが多いかも?)。


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

  2. Windows Rubyist にはおなじみのアレである。 

  3. 今年(2019 年)の春に亡くなった 著名漫画家 ではない。猿の股引きでもない。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away