rb-readlineと^Y

Gemfileにrb-readlineがあるとirbやpryで困ったことになる。入力中にうっかり^Y(Ctrl+Y)をタイプすると、いきなりプロセスがサスペンドしてしまうのだ。

^Zでプロセスをサスペンドさせるのはおなじみのジョブ制御であり、このときにはプロセスはシグナルSIGSTOPを受けることでサスペンドさせられることになる。

^Yでもプロセスをサスペンドさせられるのだが^Zとは異なる点がある。まず、^Yは入力中にだけ有効である。そして、プロセスが受けるシグナルはSIGTSTPである。(参考: termios(3)のVDSUSPの説明)

ただ、この^Yによるジョブ制御に対応しているのはmacOSやSolarisなど限られた環境だけらしい。stty -aの出力にdsusp = ^Yが含まれていれば^Yに対応している。

もっともSIGTSTPはプロセス側でシグナル処理を定義することができるので、実行中のプロセスによっては^Yによるサスペンドができない場合がある。たとえば日常的に使用する各種シェルがそうで、bashにしろzshにしろ一般的なキーバインド(emacs)での^Yはyankであり、^Kでカットした文字が貼り付けられることになる。

さて、問題のrb-readlineがどうかというと、SIGTSTPへの対処は特にしていないようだ。たとえばirbで^Yをタイプするとサスペンドしてしまうのでyankができないわけだが、^V^Yとタイプするとyankすることができる。

で、実は、そのあたりの動作は^Yのことに気付いてしまえばfgで戻るなどすればどうとでもなる、はずなのだが、どうした具合いなのか、キー入力をrb-readlineとシェルとで取り合うようなかっこうになることがある。単純にrb-readlineとirbを組み合わせるだけでは再現できなくて、手元では、たとえばrails consoleなどでそういうことが起きる場合がある。それも常にというわけではないのだが、ともあれば、一度そうなってしまうとプロセスをkillするか、端末を終了させないとどうにもならなくなってとてもめんどくさい。

というわけで、回避コードを考えてみた。

~/.pryrc:

# macOSでのrb-readline使用時の"\C-y"サスペンド問題の回避
stty_ccs = {}
nest_level = -1

Pry.hooks.add_hook(:when_started, 'avoid dsusp') do
  if defined?(::RbReadline) && $".grep(/\/rbreadline.rb\z/)
    stty = `stty -a`
    stty_ccs = { 'dsusp' => '^Y', 'lnext' => '^V' }
    stty_ccs.delete_if {|cc, key| /\b#{Regexp.quote("#{cc} = #{key}")};/ !~ stty }
  end
end

Pry.hooks.add_hook(:before_session, 'count nest level') do
  nest_level += 1
end

Pry.hooks.add_hook(:before_session, 'avoid dsusp') do |_, _, pry|
  system('stty', *stty_ccs.keys.inject([]) {|opts, cc| opts << cc << 'undef' }) if nest_level.zero? && !stty_ccs.empty?
end

Pry.hooks.add_hook(:after_session, 'avoid dsusp') do |_, _, pry|
  system('stty', *stty_ccs.to_a.flatten) if nest_level.zero? && !stty_ccs.empty?
end

Pry.hooks.add_hook(:after_session, 'count nest level') do
  nest_level -= 1
end

~/.irbrc:

# macOSでのrb-readline使用時の"\C-y"サスペンド問題の回避
if defined?(::RbReadline) && $".grep(/\/rbreadline.rb\z/)
  stty = `stty -a`
  stty_ccs = { 'dsusp' => '^Y', 'lnext' => '^V' }
  stty_ccs.delete_if {|cc, key| /\b#{Regexp.quote("#{cc} = #{key}")};/ !~ stty }

  unless stty_ccs.empty?
    system('stty', *stty_ccs.keys.inject([]) {|opts, cc| opts << cc << 'undef' })

    IRB.conf[:AT_EXIT] << proc do
      system('stty', *stty_ccs.to_a.flatten)
    end
  end
end

Pryによる~/.pryrcの読み込みは一度だけなので、Pryセッションを複数回開くケースを考えるとフックを使って制御する必要がある。

IRBはbinding.irbが呼ばれるたびに~/.irbrcが読み込まれる。その際にirbの設定(IRB.conf)が初期化されるので、Pryと比較するとわりとベタっと書いてやるだけでよい。

ついでにいろいろ確認したときのテストコード:

require 'pty'
require 'timeout'

irb_start = [
  'gem "rb-readline"', # rb-readlineを使用しない場合はコメントアウト
  'require "irb"',
  'IRB.start',
].join('; ')

timeout = 5
bash_prompt = /bash-[.\d]+\$ \z/
irb_prompt = />> \z/

ios = [
  bash_prompt,
  "echo cat test\n",
  bash_prompt,
  "cat\n",
  "aaa\n",
  "bbb\n",
  "\C-d",
  bash_prompt,
  "echo readline test\n",
  bash_prompt,
  "ruby -e '#{irb_start}' -- -f --prompt-mode=simple\n",
  irb_prompt,
  ":ccc\n",
  irb_prompt,
  ":ddd", "\C-a", "\C-k", "eee = ", "\C-y", "\n",
  irb_prompt,
  ":fff\n",
  irb_prompt,
  "quit\n",
  bash_prompt,
  "echo done.\n",
  bash_prompt,
  "exit\n",
]

result = 'OK'
wait = nil
buf = ''

PTY.spawn('bash --norc') do |r, w, _p|
  w.sync = true

  begin
    Timeout.timeout(timeout) do
      ios.each do |obj|
        if obj.is_a?(String)
          w.write obj
          $stderr.write '+'
          next
        end

        wait = obj
        loop do
          buf << r.readpartial(1024)
          break if wait.match(buf)
          $stderr.write '-'
          sleep 0.1
        end
        $stderr.write '.'
      end
    end

    buf << r.readpartial(1024)
  rescue Timeout::Error
    result = "ERROR: couldn't get #{wait.inspect}"
  ensure
    w.close
    r.close
  end
end

$stderr.write "\n#{'-' * 78}\n"
print "RESULT = #{result}\n\n#{buf}"

^YはmacOSなどでしか使えないのでLinux環境では動作しない。また、端末制御の関係なのかSSH経由でもうまく動作しない場合がある。(vagrant sshとか)

rb-readlineなしの場合(通常期待される動作):

$ ruby test.rb
.+-.++++-.+-.+--.+--.++++++---.+--.+-.+-.+
------------------------------------------------------------------------------
RESULT = OK

bash-3.2$ echo cat test
cat test
bash-3.2$ cat
aaa
bbb
aaa
bbb
bash-3.2$ echo readline test
readline test
bash-3.2$ ruby -e 'require "irb"; IRB.start' -- -f --prompt-mode=simple
>> :ccc
=> :ccc
>> eee = :ddd
=> :ddd
>> :fff
=> :fff
>> quit
bash-3.2$ echo done.
done.

rb-readlineありの場合:

$ ruby test.rb
-.+-.++++-.+-.+--.+-.++++++---
------------------------------------------------------------------------------
RESULT = ERROR: couldn't get />> \z/

bash-3.2$ echo cat test
cat test
bash-3.2$ cat
aaa
bbb
aaa
bbb
bash-3.2$ echo readline test
readline test
ode=simpleruby -e 'gem "rb-readline"; require "irb"; IRB.start' -- -f --prompt-m
>> :ccc
=> :ccc
>> eee =
[1]+  Stopped                 ruby -e 'gem "rb-readline"; require "irb"; IRB.start' -- -f --prompt-mode=simple
bash-3.2$