nyagos が Ctrl-C で終了してしまう件
これ、当初は more 実行中に Ctrl-C を押下すると落ちてしまうというケースを書いていたんだけど、その後、いろいろと知らなかった想定外終了ケースを報告していただいている。
Ctrl-C を効かなくするには二種類の方法がある
- SetConsoleMode で、ENABLE_PROCESSED_INPUT をオンだかオフだかする(どっちやねん)
- Ctrl-C を押下しても、SIGINT が送られなくなる
- プロセスではなくコンソールの設定をいじるので、自プロセスだけでなく子プロセスも Ctrl-C が効かなくなる。
- Windows 独自の設定なので、移植性がちょっと落ちる
- signal 関数で SIGINT を無視する感じにする
nyagosは当初 1. の方を使っていて、これをやると落ちるなんてことは絶対なくなるんだけど、逆に子プロセスを kill できなくなるので、現在は 2. の方を採用している。ただし、SIGINT を完全に無視するんじゃなくて、SIGINT をキャッチしたら子プロセスを kill するというような動作をする goroutine を走らせている。
SIGINT をキャッチしたら子プロセスを kill する
あ、厳密にいうと、
- SIGINT をキャッチしたら、context.WithCancel を使って現在やっている処理にキャンセル処理を通知する( shell/loop.go の (*Shell)Loop 関数内 )
- 外部プロセスの終了待ちをしている goroutine がキャンセルを検知すると、外部プロセスに kill する( shell/interpret.go の (*Shell)startAndWaitProcess 関数内 )
という動作をしています
一つの可能性としては、signal.Notify (禁止)と signal.Stop (解除)が頻繁すぎて、そこにスキが出来ているというのもあるかもしれない
- シェルの基本ループ (shell/Loop.go の (*Shell)Loop関数
- 一行入力 in "go-readline-ny"
- 1文字キー入力( "mattn/go-tty" )
- (tty.*TTY)Raw()実行時にENABLE_PROCESSED_INPUT がリセットされ、Raw()解除時に元に戻される
- この間、Ctrl-C で割り込み(SIGINT)自体が発生しない
- 1文字キー入力( "mattn/go-tty" )
- コマンド実行部 (shell/Interpreter.go)
- 1文ごとに signal.Notify → コマンド実行 → signal.Stop を繰り返す
- siginal.Notify~Stopの間、SIGINT は context のキャンセルに解釈されなおす
- 一行入力 in "go-readline-ny"
一行入力とコマンド実行部との遷移の間が割り込みを受け付ける隙になっている可能性が高い。signal 操作と go-tty は衝突しないので、ループの外側に signal.Notify~Stop の範囲を広げてみるか
トップレベルで、signal.Ignore(os.Interrpt) を入れてみました。多分、下位の signal.Notify/Stop や ENABLE_PROCESSED_INPUTのリセットと干渉しないので、これで直るはず…
この修正は "frame" というサブパッケージでやってますが、これは何かというと main から数えて呼び出し階層の2番目くらいにあるサブパッケージです。
- main - 形式だけのトップレベル(frameとmainsを呼ぶだけ)
-
frame - Lua に依存しない部分の初期化処理と終了処理を担う
- mains - Lua に依存する部分の初期化処理と終了処理とメインルーチン
- shell - シェルのメインルーチン(loop.go)とコマンドインタープリタ(interpreter.go)
- "go-readline-ny" - 一行入力のパッケージ(元々はサブパッケージだったが独立させた)
- mains - Lua に依存する部分の初期化処理と終了処理とメインルーチン
-
frame - Lua に依存しない部分の初期化処理と終了処理を担う
複雑な階層構造になってますが、これは Lua に依存する箇所を mains だけに限定したかったためです。これをしなければ、lua53.dll → GopherLua へのスムーズな切り替えが出来なかったと思います。
直っていない。
start nyagos.exe
more
[Ctrl-C]
で、ターミナルがクローズしてしまう。別のウインドウなので、親の nyagos が kill しているわけではない
more を実行する時に一時的に ENABLE_PROCESSED_INPUT をリセットして、Ctrl-C を押されても割り込みが発生しないようにした。
が、そうすると、キーボード入力の Enter が LF のない CR だけになってしまって、ずっと同じ行で入力し続ける形になっておかしい!
(が、落ちるよりマシなので、とりあえず commit してる。しかし、コレ、どうすりゃいいんだ?)
Windows Terminal や VSCode などでターミナルを開くとき、pwsh や nyagos の中で時間のかかる処理をCtrl+Cするとターミナルのプロセスごと落ちてしまうときがある
対処法
scoop/shimsではなくscoop/apps以下の .exe を指定する
scoop/shims 以下にある実行ファイルは、scoop/apps 以下にある本物の EXE ファイルを呼ぶためのブースター。下記がソースだが、ぱっと見、子プロセスを kill するようなコードは見受けられない。プロセスツリーのトップが殺されたら連鎖的に子供も殺されるような仕組みでもあるのかなぁ
zenn.dev の Book 「scoop / nyagos で始めるコマンドライン生活」が ~\scoop/apps/nyagos.exe を使うような記載になっていたのを、~\scoop\apps\nyagos\current\nyagos.exe を指定するよう修正した。
この記事が迷える人を増やす要因になっていた。もっと早く修正すべきだった。判断が遅い!
あー。うちの記事でも当時のサンプルコードをそのまま載せてました。直さなきゃ
clone コマンドで、新ターミナルを起動し、プロンプトが出るか出ないかするタイミングで Ctrl-C を押下したら落ちた!
signal.Stop(signals)
これを呼び出すと、それ以降Notify()で指定したシグナルを受け取らなくなるわけではなく、デフォルトに戻るようです。 Notify()でSIGINT(Ctrl + C)を指定していた場合、呼び出し後はブロックせずにデフォルトでプロセスを終了するようになります
な、なんだと…
(でも、Stop の後、ほとんど間もなく、readline(go-tty) 中で SetConsoleMode( ENABLE_PROCESSED_INPUT のリセット) とかしているので、これがすべての根源とも言い切れない。
が、対処しておく必要はあるだろう。
// 余談だが、context でキャンセルさせるようにしてから、Ctrl-C の面倒事が増えてきたような気がする。まぁ、context がなかった当時は内蔵 more もなかったけど
Ctrl-C 関連を改造中の nyagos にて
$ more
f
f
d
d
w
w
<DESKTOP-LGGUCRA:~/go/src/github.com/zetamatta/nyagos>
$
<DESKTOP-LGGUCRA:~/go/src/github.com/zetamatta/nyagos>
$ s
C:\Users\hymko\go\src\github.com\zetamatta\nyagos\nyagos.d\brace.lua:8: context canceled
stack traceback:
C:\Users\hymko\go\src\github.com\zetamatta\nyagos\nyagos.d\brace.lua:8: in main chunk
[G]: ?
more の中で Ctrl-C で止めると… 時間差で Cancel される。
(brace.lua というのは、A{B,C}D を ABD ACD へ変換するコマンドラインフィルターの Lua で、それが context.WithCancel の cancel で止められてる。なんでや!
もうかなり行き詰まってる
あー、context.WithCancel の .Done() と signal のチャネルを同じ場所で拾わせるという手があるのか
signal.Notify(c,
syscall.SIGINT,
os.Interrupt)
go func() {
select {
case <-c:
// シグナルをキャッチ
fmt.Println("signal")
// robot() を終了させる
cancel()
case <-ctx.Done():
// cancel() コール時に呼ばれる、ただしシグナルをキャッチした際の cancel() ではここは通らない
debug()
fmt.Println("ctx.Done()")
}
}()
なるほど
signal.Notify~signal.Stopの範囲の見直しする必要がありそうだ。
signal.Notify の後、signal.Stop をすると、Ctrl-C でのプロセス終了状態に戻ってしまうという記事があった。では、signal.Notify の入れ子はどうすればよいのか。検証してみよう
単純に channel をクローズしてはどうか?
package main
import (
"time"
"os/signal"
"os"
)
func main(){
c1 := make(chan os.Signal,1)
c2 := make(chan os.Signal,1)
go func(){
for _ = range c1{
println("first signal")
}
}()
go func(){
for _ = range c2{
println("second signal")
}
}()
signal.Notify(c1,os.Interrupt)
signal.Notify(c2,os.Interrupt)
println("Type Ctrl-C in 10 seconds")
time.Sleep(time.Second*10)
close(c1)
println("Type Ctrl-C in 10 seconds")
time.Sleep(time.Second*10)
close(c2)
}
$ signalnotify.exe
Type Ctrl-C in 10 seconds
first signal
second signal
Type Ctrl-C in 10 seconds
panic: send on closed channel
goroutine 8 [running]:
os/signal.process(0x64ae00, 0x697710)
C:/go/src/os/signal/signal.go:244 +0x17f
os/signal.loop()
C:/go/src/os/signal/signal_unix.go:23 +0x45
created by os/signal.Notify.func1.1
C:/go/src/os/signal/signal.go:150 +0x4b
ダメに決まってるだろ!
close ではなく、普通に signal.Stop を使ってみた。
signal.Notify(c1,os.Interrupt)
signal.Notify(c2,os.Interrupt)
println("Type Ctrl-C in 10 seconds")
time.Sleep(time.Second*10)
signal.Stop(c2)
println("Type Ctrl-C in 10 seconds")
time.Sleep(time.Second*10)
signal.Stop(c1)
println("All done")
$ signalnotify.exe
Type Ctrl-C in 10 seconds
second signal
first signal
Type Ctrl-C in 10 seconds
first signal
All done
普通に行けた。signal.Notify は多重に機能する。
さて、全部の signal.Notify を解除した時の挙動は、何もしなかった時の状況に戻るわけだが、その時の挙動はどうか?
signal.Notify(c1,os.Interrupt)
signal.Notify(c2,os.Interrupt)
println("Type Ctrl-C in 5 seconds")
time.Sleep(time.Second*5)
signal.Stop(c2)
println("Type Ctrl-C in 5 seconds")
time.Sleep(time.Second*5)
signal.Stop(c1)
println("Type Ctrl-C in 5 seconds")
time.Sleep(time.Second*5)
println("All done")
$ signalnotify.exe
Type Ctrl-C in 5 seconds
second signal
first signal
Type Ctrl-C in 5 seconds
first signal
Type Ctrl-C in 5 seconds
3回 Ctrl-C を押下した時、Notify をすべて解除した時はプロセスが殺されている( All done
が表示されない )。
では、最初に signal.Ignore を設定してはどうか?
signal.Ignore(os.Interrupt)
signal.Notify(c1, os.Interrupt)
signal.Notify(c2, os.Interrupt)
println("Type Ctrl-C in 5 seconds")
time.Sleep(time.Second * 5)
signal.Stop(c2)
println("Type Ctrl-C in 5 seconds")
time.Sleep(time.Second * 5)
signal.Stop(c1)
println("Type Ctrl-C in 5 seconds")
time.Sleep(time.Second * 5)
println("All done")
$ signalnotify.exe
Type Ctrl-C in 5 seconds
second signal
first signal
Type Ctrl-C in 5 seconds
first signal
Type Ctrl-C in 5 seconds
うん、ダメっぽい!
signal.Notify が一つでも残っていれば Ctrl-C で強制停止されないわけだから、まぁ、大丈夫と見てよいかな…
(一つも signal.Notify が設定されていない状態というのは、シェルのメイン処理ループ外にいるということなので。そういうタイミングはほとんどない)
処理しない signal.Notify を用意してもいいけど、そちらの channel が Ctrl-C で満タンになると panic になるので、消し込むためだけの goroutine が必要になって、もったいない。
対処法
scoop/shimsではなくscoop/apps以下の .exe を指定する
これをやらなくても、落ちなくならなくなっているだろうか?
いけてそうな感じではあるけれども、疑似テスト(自分で、~/scoop/app/nyagos/4.4.8_0/nyagos を差し替えた)結果なので、まぁ、直ったとは断言できぬ状況
落ちなくはなったけど、more がエコーバックしないという問題がまだ残っていた。
どうも、more を実行する前に ls を実行していたりすると、SetConsoleMode がエラー終了していることが分かった。成功する時は
windows.SetConsoleMode(標準入力のコンソールハンドル,0x7)
となっているが、失敗する時は次のようになっていた。
windows.SetConsoleMode(標準入力のコンソールハンドル,0x5)
なので今まで UNIX の Cocked Mode 相当に遷移する際:
- 0x1 = ENABLE_PROCESSED_INPUT
- 0x4 = ENABLE_ECHO_INPUT
だけを明示的にセットしていたのを
- 0x2 = ENABLE_LINE_INPUT
も合わせてセットしなければいけない(組み合わせ上認められない)ということになりそうだ。
more がエコーバックしないという問題は、前述の方法で解決した。
だが、2回目以降の more で、なぜか先頭行にいきなり空行が入ってしまうという問題が…なぜ?
こちらについては [Go]os.StdinのEOFの後に改行が必ず入ってきてしまう件 にて検討します。
(more がエコーバックしない件も別にすればよかった…)
詳しくは Ctrl-C kills nyagos.exe itself when more
runs without redirect · Issue #342 · zetamatta/nyagos 以下に書きましたが
- context.WithCancel の .Done() と signal のチャネルを同じ場所で拾わせる
- signal.Notify ~ signal.Stop の範囲を見直し(シグナルを拾い損ねる隙をなくす)
- context.WithCancel のキャンセルが来ても、外部プロセスを kill させず、ターミナルからの各プロセスへの sigint の送信に任せる
ようにしたところ、Ctrl-C まわりの諸問題は一応解決したようです。これらの対応はnyagos 4.4.9_2 に反映されています。
本スクラップはこれにてクローズしたいと思います。ありがとうございました。