レビューに参加しました
コマンドラッパーとはコマンドを引数で受け取ってそれを実行しつつ別のこともやってくれるもの。
プロジェクトで独自のラッパーシェルを作ることも多いでしょう
テストにコケる度にシーザーが死ぬ仕組みを作りました by moznion
https://moznion.hatenadiary.com/entry/20130305/1362467136
% cpanm App::WithSound
% with-sound /path/to/cmd
コマンド実行中に音を出す、コマンド失敗したら別の音を出す
簡単!
import "os/exec"
cmd := exec.CommandContext("/path/to/command", "option...")
// 同期的呼び出し
err := cmd.Run()
// バックグランド呼び出し
cmd.Start()
// do something
cmd.Wait()
cmd.Output/CombinedOutput
などもあるけど取り上げませんbytes.Buffer
が基本。io.Reader/Writerをimplしてる便利なやつ。
var outbuf, errbuf bytes.Buffer
cmd.Stdin = &outbuf
cmd.Stderr = &errbuf
err := cmd.Run()
fmt.Println("stdout: " + outbuf.String())
fmt.Println("stderr: " + errbuf.String())
io.MultiWriter
で複数Writerに書き出せる
var outbuf, errbuf, mergedBuf bytes.Buffer
outWtr := io.MultiWriter(&outbuf, &mergedBuf)
errWtr := io.MultiWriter(&errbuf, &mergedBuf)
cmd.Stdin = outWtr
cmd.Stderr = errWtr
err := cmd.Run()
fmt.Println("stdout: " + outbuf.String())
fmt.Println("stderr: " + errbuf.String())
fmt.Println("merged: " + mergedBuf.String())
cmd.StdoutPipe()
で読み出しパイプを作り、io.TeeReader
で別の場所に書き出したあと、io.Copy
を使ってos.Stdout
に書き戻している。
pipe, err := cmd.StdoutPipe()
var buf bytes.Buffer
reader := io.TeeReader(pipe, buf)
err := cmd.Start()
_, err := io.Copy(os.Stdout, reader)
err := cmd.Wait()
fmt.Println(buf.String())
(MultiWriterを使っても同様のことは実現できるけど説明のため)
golang.org/x/text/transform.Transformer が便利
import "github.com/Songmu/timestamper"
import "golang.org/x/text/transform"
pipe, err := cmd.StdoutPipe()
defer pipe.Close()
reader := transform.NewReader(pipe, timestamper.New())
err := cmd.Start()
_, err := io.Copy(os.Stdout, reader)
err := cmd.Wait()
コマンドの標準出力もエラー出力もOSの出力にそれぞれ出しつつ、標準出力とエラー出力の内容はそれぞれ変数に保持し、マージ出力も保持しマージ出力にはタイムスタンプを付与する。
みたいなこともできるようになります。horensoは内部的にはそういうことをやっている。
backtickやsystem()などの同期呼び出し方法はあるが、バックグラウンドで呼び出そうとすると大変。
pipe開いて, forkしてpipeつないで、不必要なFDはちゃんと閉じる
my @command = ('path/to/command');
pipe my $rpipe, $wpipe;
unless (my $pid = fork) {
if (defined $pid) {
# child process
close $rpipe; close $wpipe;
open STDERR, '>&', $wpipe;
open STDOUT, '>&', $wpipe;
exec @command;
die "exec(2) failed:$!";
} else {
close $rpipe; close $wpipe;
die "fork(2) failed:$!";
}
} else {
# main process
close $wpipe;
while (my $log <$wpipe>) {
# do something
}
close $wpipe;
}
bytes.Buffer
io.MultiWriter
io.TeeReader
io.Copy
golang.org/x/text/transform.Transformer
kill - terminate a process
The command kill sends the specified signal to the specified process or process group
-- man 1 kill
killコマンドとはプロセスまたはプロセスグループにシグナルを送るものである。
プロセスはシグナルをトラップし、ハンドリングすることができる。以下のようなことも可能。
ただしSIGKILLとSIGSTOPはハンドリングできない。
プロセスを停止させたい場合はまずはSIGTERMで正常終了を促すのがお作法。それでも止まらない場合はSIGKILLを送る。
標準に exec.CommandContext
がある
ctx, cancel := context.WithCancel(context.Background())
cmd := command.CommandContext(ctx, "sleep", "100")
err := cmd.Start()
cancel() // <- 停止
err = cmd.Wait()
ctx, cancel := context.WithCancel(context.Background(), time.Second*10)
defer cancel()
cmd := command.CommandContext(ctx, "sleep", "100")
err := cmd.Run() // <- 10秒で停止
func (c *Cmd) Start()
抜粋
select {
case <-c.ctx.Done():
c.Process.Kill() // <- 内部的に p.Signal(Kill) 呼び出してるいる…!
case <-c.waitDone:
}
sh -c
の場合2000 pts/0 Ss 0:00 -main
2001 pts/0 S+ 0:00 \_ sh -c echo; sleep 100000000
2002 pts/0 S+ 0:00 \_ sleep 100000000
exec.CommandContext(ctx, "sh", "-c", "echo; sleep 100000000")
の場合
sh
のプロセス(この場合PID2001)のみにシグナルが送られそれが停止するsleep
(PID2002)は生き残り、孤児プロセスとなってinitに引き取られるsleep
だとそれほど害はないが、シェル経由で呼び出したコマンドが暴走した場合、
sh
を止めたとしても暴走プロセスは止まらないため、リソースを食い続けることになる。
など
https://github.com/golang/go/issues/21135
いろいろな提案がされたものの、Russ Cox氏直々にClose。「標準じゃないパッケージでやればいいよね」
一定時間を超えたコマンドをタイムアウトさせ、その際コマンドを正しく終了させる。
import "github.com/Songmu/timeout"
cmd := exec.Command("/path/to/command")
tio := &timeout.Timeout{
Cmd: cmd,
Duration: 10 * time.Second,
KillAfter: 5 * time.Second,
Signal syscall.SIGTERM, // Default SIGTERM on Unix
}
err := tio.RunContext(context.Background())
% go get github.com/Songmu/timeout/cmd/go-timeout
% go-timeout 30 /path/to/command
https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html
coreutilsに入っている、一定実行時間をコマンドをタイムアウトさせるコマンド。
% timeout 30 /path/to/command
Macだと brew install coreutils
で入る。cronなどで便利。バッチスキー(バッチが好きな人)の道具箱に常備されている。(他の便利道具に setlock
, sotflimit
等がある)
このあたりは、GNU timeoutとだいたい同じことをやっている
それぞれアプローチが異なる。
setpgid (0, 0)
man 2 kill
)If pid equals 0, then sig is sent to every process in the process group of the calling process.
/* timeout.c */
static int
send_sig (pid_t where, int sig)
{
/* If sending to the group, then ignore the signal,
so we don't go into a signal loop. Note that this will ignore any of the
signals registered in install_cleanup(), that are sent after we
propagate the first one, which hopefully won't be an issue. Note this
process can be implicitly multithreaded due to some timer_settime()
implementations, therefore a signal sent to the group, can be sent
multiple times to this process. */
if (where == 0)
signal (sig, SIG_IGN);
return kill (where, sig);
}
呼び出しプロセス をプロセスグループリーダーにしている。
func (tio *Timeout) getCmd() *exec.Cmd {
if tio.Cmd.SysProcAttr == nil {
tio.Cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
return tio.Cmd
}
そしてそのプロセスグループに対してシグナルを送っている(後述)
func Kill(pid int, signum syscall.Signal) (err error)
cmd.Process.Signal(sig os.Signal)
ではプロセスグループにシグナルを送れないので、syscall.Kill
にpidの負値を渡す。
また、Process.Signal()
は os.Signal
を受け取るが、 syscall.Kill()
が受け取るのは syscall.Signal
である点も注意。
syssig, ok := sig.(syscall.Signal)
err := syscall.Kill(-cmd.Process.Pid, syssig)
これで完璧か?
シグナルを送ったあとに、更にSIGCONTを送っている。これはどういうことか。
/* The normal case is the job has remained in our
newly created process group, so send to all processes in that. */
if (!foreground)
{
send_sig (0, sig);
if (sig != SIGKILL && sig != SIGCONT)
{
send_sig (monitored_pid, SIGCONT);
send_sig (0, SIGCONT);
}
}
→SIGCONTを送ることで強制的に再開させる
これで正しくプロセスを停止させられるようになりました。
func (tio *Timeout) getCmd() *exec.Cmd {
if tio.Cmd.SysProcAttr == nil {
tio.Cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
return tio.Cmd
}
func (tio *Timeout) terminate() error {
sig := tio.signal()
syssig, ok := sig.(syscall.Signal)
if !ok || tio.Foreground {
return tio.Cmd.Process.Signal(sig)
}
err := syscall.Kill(-tio.Cmd.Process.Pid, syssig)
if err != nil {
return err
}
if syssig != syscall.SIGKILL && syssig != syscall.SIGCONT {
return syscall.Kill(-tio.Cmd.Process.Pid, syscall.SIGCONT)
}
return nil
}
WaitStatus.ExitStatus()
はシグナルを受けたときには常時-1になるこのサンプルでは、 w.Signaled()
で判定して、シグナルを受けた場合は、 シグナル番号に128を足した値を返却している。ただ、この128を加算するルールもあくまでシェルのルールであるため、厳密とは言えないかもしれない。
err := cmd.Wait()
exitCode, signaled := resolveExitCode(err)
func resolveExitCode(err error) (int, bool) {
if err != nil {
if exiterr, ok := err.(*exec.ExitError); ok {
if status, ok := exiterr.Sys().(syscall.WaitStatus); ok {
if w.Signaled() {
return int(w.Signal())+128, true
}
return status.ExitStatus(), false
}
}
// The exit codes in some platforms aren't integer. e.g. plan9.
return -1, false
}
return 0, false // 正常終了
}
https://github.com/Songmu/wrapcommander
WaitStatus.Signaled
が存在しない
→ wrapcommanderはそのあたりをいい感じに解決してくれます
var est *wrapcommander.ExitStatus = wrapcommander.ResolveExitStatus(err)
cmd := exec.Command("/path/to/cmd")
cmd.Run()
exitCode := cmd.ProcessState.ExitCode()
これもシグナル終了かどうかを取りたい場合は、以下のように syscall.WaitStatus
を取り出してください。
st, ok := cmd.Process.Sys().(syscall.WaitStatus)