├── testdata ├── dummy ├── .gitignore └── stubcmd.go ├── .gitignore ├── version.go ├── .travis.yml ├── go.mod ├── cmd └── go-timeout │ ├── README.md │ ├── signal_others.go │ ├── signal_windows.go │ ├── main_test.go │ └── main.go ├── appveyor.yml ├── go.sum ├── timeout_windows.go ├── timeout_unix.go ├── README.md ├── LICENSE ├── exitstatus.go ├── Makefile ├── timeout_unix_test.go ├── CHANGELOG.md ├── CREDITS ├── timeout.go └── timeout_test.go /testdata/dummy: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/.gitignore: -------------------------------------------------------------------------------- 1 | stubcmd 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.gitignore 3 | !.travis.yml 4 | dist/ 5 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package timeout 2 | 3 | const version = "0.4.0" 4 | 5 | var revision = "Devel" 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | os: 3 | - linux 4 | - osx 5 | go: 6 | - tip 7 | script: 8 | - make lint 9 | - make test 10 | after_script: 11 | - make cover 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Songmu/timeout 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/Songmu/wrapcommander v0.1.0 7 | github.com/pborman/getopt v0.0.0-20190409184431-ee0cd42419d3 8 | ) 9 | -------------------------------------------------------------------------------- /cmd/go-timeout/README.md: -------------------------------------------------------------------------------- 1 | go-timeout 2 | ========== 3 | 4 | golang porting of GNU timeout. And it is a sample code of github.com/Songmu/timeout 5 | 6 | ## Installation 7 | 8 | % go get github.com/Songmu/timeout/cmd/go-timeout 9 | 10 | ## Synopsis 11 | 12 | % go-timeout 10 /path/to/command [options] 13 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | version: "{build}" 2 | clone_folder: c:\gopath\src\github.com\Songmu\timeout 3 | environment: 4 | GOPATH: c:\gopath 5 | build: false 6 | install: 7 | - copy c:\MinGW\bin\mingw32-make.exe c:\MinGW\bin\make.exe 8 | - set PATH=%GOROOT%\bin;%GOPATH%\bin;c:\MinGW\bin;%PATH% 9 | test_script: 10 | - make lint 11 | - make test 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Songmu/wrapcommander v0.1.0 h1:y8/yk9/PHT983weH+ehZIOJ7JtwAlI1AkfUpUNCj1SY= 2 | github.com/Songmu/wrapcommander v0.1.0/go.mod h1:EC2y4OnN8PkdMnaCwcSzItewq+f0yqUvS30kcS4vmn0= 3 | github.com/pborman/getopt v0.0.0-20190409184431-ee0cd42419d3 h1:YtFkrqsMEj7YqpIhRteVxJxCeC3jJBieuLr0d4C4rSA= 4 | github.com/pborman/getopt v0.0.0-20190409184431-ee0cd42419d3/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= 5 | -------------------------------------------------------------------------------- /timeout_windows.go: -------------------------------------------------------------------------------- 1 | package timeout 2 | 3 | import ( 4 | "os/exec" 5 | "strconv" 6 | "syscall" 7 | ) 8 | 9 | func (tio *Timeout) getCmd() *exec.Cmd { 10 | if !tio.Foreground && tio.Cmd.SysProcAttr == nil { 11 | tio.Cmd.SysProcAttr = &syscall.SysProcAttr{ 12 | CreationFlags: syscall.CREATE_UNICODE_ENVIRONMENT | 0x00000200, 13 | } 14 | } 15 | return tio.Cmd 16 | } 17 | 18 | func (tio *Timeout) terminate() error { 19 | return tio.Cmd.Process.Signal(tio.signal()) 20 | } 21 | 22 | func (tio *Timeout) killall() error { 23 | return exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(tio.Cmd.Process.Pid)).Run() 24 | } 25 | -------------------------------------------------------------------------------- /cmd/go-timeout/signal_others.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "strings" 9 | "syscall" 10 | ) 11 | 12 | func parseSignal(sigStr string) (os.Signal, error) { 13 | switch strings.ToUpper(sigStr) { 14 | case "": 15 | return nil, nil 16 | case "HUP", "1": 17 | return syscall.SIGHUP, nil 18 | case "INT", "2": 19 | return os.Interrupt, nil 20 | case "QUIT", "3": 21 | return syscall.SIGQUIT, nil 22 | case "KILL", "9": 23 | return os.Kill, nil 24 | case "ALRM", "14": 25 | return syscall.SIGALRM, nil 26 | case "TERM", "15": 27 | return syscall.SIGTERM, nil 28 | case "USR1": 29 | return syscall.SIGUSR1, nil 30 | case "USR2": 31 | return syscall.SIGUSR2, nil 32 | default: 33 | return nil, fmt.Errorf("%s: invalid signal", sigStr) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /cmd/go-timeout/signal_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "strings" 9 | "syscall" 10 | ) 11 | 12 | func parseSignal(sigStr string) (os.Signal, error) { 13 | switch strings.ToUpper(sigStr) { 14 | case "": 15 | return nil, nil 16 | case "HUP", "1": 17 | return syscall.SIGHUP, nil 18 | case "INT", "2": 19 | return os.Interrupt, nil 20 | case "QUIT", "3": 21 | return syscall.SIGQUIT, nil 22 | case "KILL", "9": 23 | return os.Kill, nil 24 | case "ALRM", "14": 25 | return syscall.SIGALRM, nil 26 | case "TERM", "15": 27 | return syscall.SIGTERM, nil 28 | case "USR1": 29 | return nil, syscall.EWINDOWS 30 | case "USR2": 31 | return nil, syscall.EWINDOWS 32 | default: 33 | return nil, fmt.Errorf("%s: invalid signal", sigStr) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /timeout_unix.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package timeout 4 | 5 | import ( 6 | "os/exec" 7 | "syscall" 8 | ) 9 | 10 | func init() { 11 | defaultSignal = syscall.SIGTERM 12 | } 13 | 14 | func (tio *Timeout) getCmd() *exec.Cmd { 15 | if tio.Cmd.SysProcAttr == nil { 16 | tio.Cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 17 | } 18 | return tio.Cmd 19 | } 20 | 21 | func (tio *Timeout) terminate() error { 22 | sig := tio.signal() 23 | syssig, ok := sig.(syscall.Signal) 24 | if !ok || tio.Foreground { 25 | return tio.Cmd.Process.Signal(sig) 26 | } 27 | err := syscall.Kill(-tio.Cmd.Process.Pid, syssig) 28 | if err != nil { 29 | return err 30 | } 31 | if syssig != syscall.SIGKILL && syssig != syscall.SIGCONT { 32 | return syscall.Kill(-tio.Cmd.Process.Pid, syscall.SIGCONT) 33 | } 34 | return nil 35 | } 36 | 37 | func (tio *Timeout) killall() error { 38 | return syscall.Kill(-tio.Cmd.Process.Pid, syscall.SIGKILL) 39 | } 40 | -------------------------------------------------------------------------------- /cmd/go-timeout/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestParseDuration(t *testing.T) { 6 | v, err := parseDuration("55s") 7 | if err != nil { 8 | t.Errorf("something wrong") 9 | } 10 | if v != 55 { 11 | t.Errorf("parse failed!") 12 | } 13 | 14 | v, err = parseDuration("55") 15 | if err != nil { 16 | t.Errorf("something wrong") 17 | } 18 | if v != 55 { 19 | t.Errorf("parse failed!") 20 | } 21 | 22 | v, err = parseDuration("10m") 23 | if err != nil { 24 | t.Errorf("something wrong") 25 | } 26 | if v != 600 { 27 | t.Errorf("parse failed!") 28 | } 29 | 30 | v, err = parseDuration("1h") 31 | if err != nil { 32 | t.Errorf("something wrong") 33 | } 34 | if v != 3600 { 35 | t.Errorf("parse failed!") 36 | } 37 | 38 | v, err = parseDuration("1d") 39 | if err != nil { 40 | t.Errorf("something wrong") 41 | } 42 | if v != 86400 { 43 | t.Errorf("parse failed!") 44 | } 45 | 46 | _, err = parseDuration("1w") 47 | if err == nil { 48 | t.Errorf("something wrong") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | timeout 2 | ======= 3 | 4 | [![Build Status](https://travis-ci.org/Songmu/timeout.png?branch=master)][travis] 5 | [![Coverage Status](https://coveralls.io/repos/Songmu/timeout/badge.png?branch=master)][coveralls] 6 | [![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)][license] 7 | [![GoDoc](https://godoc.org/github.com/Songmu/timeout?status.svg)][godoc] 8 | 9 | [travis]: https://travis-ci.org/Songmu/timeout 10 | [coveralls]: https://coveralls.io/r/Songmu/timeout?branch=master 11 | [license]: https://github.com/Songmu/timeout/blob/master/LICENSE 12 | [godoc]: https://godoc.org/github.com/Songmu/timeout 13 | 14 | Timeout invocation. Go porting of GNU timeout 15 | 16 | ## Description 17 | 18 | Run a given command with a time limit. 19 | 20 | ## Synopsis 21 | 22 | tio := &timeout.Timeout{ 23 | Cmd: exec.Command("perl", "-E", "say 'Hello'"), 24 | Duration: 10 * time.Second, 25 | KillAfter: 5 * time.Second, 26 | } 27 | exitStatus, stdout, stderr, err := tio.Run() 28 | 29 | ## Author 30 | 31 | [Songmu](https://github.com/Songmu) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Songmu 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /testdata/stubcmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "os/signal" 8 | "strings" 9 | "syscall" 10 | "time" 11 | ) 12 | 13 | var sigmap = map[string]os.Signal{ 14 | "INT": os.Interrupt, 15 | "TERM": syscall.SIGTERM, 16 | } 17 | 18 | func main() { 19 | var ( 20 | trap = flag.String("trap", "", "signals") 21 | exit = flag.Int("exit", 0, "exit status") 22 | trapExit = flag.Int("trap-exit", 0, "exit status when trapping signal") 23 | sleep = flag.Float64("sleep", 0, "sleep seconds") 24 | ) 25 | flag.Parse() 26 | 27 | if *trap != "" { 28 | var sigs []os.Signal 29 | for _, sigStr := range strings.Split(*trap, ",") { 30 | sig, ok := sigmap[strings.TrimPrefix(strings.ToUpper(sigStr), "SIG")] 31 | if !ok { 32 | log.Printf("unknown signal name: %s\n", sigStr) 33 | os.Exit(1) 34 | } 35 | sigs = append(sigs, sig) 36 | } 37 | c := make(chan os.Signal, 1) 38 | signal.Notify(c, sigs...) 39 | go func() { 40 | for _ = range c { 41 | if *trapExit > 0 { 42 | os.Exit(*trapExit) 43 | } 44 | } 45 | }() 46 | } 47 | if *sleep > 0 { 48 | time.Sleep(time.Duration(float64(time.Second) * *sleep)) 49 | } 50 | os.Exit(*exit) 51 | } 52 | -------------------------------------------------------------------------------- /exitstatus.go: -------------------------------------------------------------------------------- 1 | package timeout 2 | 3 | // ExitStatus stores exit information of the command 4 | type ExitStatus struct { 5 | Code int 6 | Signaled bool 7 | typ exitType 8 | killed bool 9 | } 10 | 11 | // IsTimedOut returns the command timed out or not 12 | func (ex *ExitStatus) IsTimedOut() bool { 13 | return ex.typ == exitTypeTimedOut || ex.typ == exitTypeKilled 14 | } 15 | 16 | // IsCanceled return if the command canceled by context or not 17 | func (ex *ExitStatus) IsCanceled() bool { 18 | return ex.typ == exitTypeCanceled 19 | } 20 | 21 | // IsKilled returns the command is killed or not 22 | func (ex *ExitStatus) IsKilled() bool { 23 | return ex.killed 24 | } 25 | 26 | // GetExitCode gets the exit code for command line tools 27 | func (ex *ExitStatus) GetExitCode() int { 28 | switch { 29 | case ex.IsKilled(): 30 | return exitKilled 31 | case ex.IsTimedOut(): 32 | return exitTimedOut 33 | default: 34 | return ex.Code 35 | } 36 | } 37 | 38 | // GetChildExitCode gets the exit code of the Cmd itself 39 | func (ex *ExitStatus) GetChildExitCode() int { 40 | return ex.Code 41 | } 42 | 43 | type exitType int 44 | 45 | // exit types 46 | const ( 47 | exitTypeNormal exitType = iota 48 | exitTypeTimedOut 49 | exitTypeKilled 50 | exitTypeCanceled 51 | ) 52 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION = $(shell godzil show-version) 2 | CURRENT_REVISION = $(shell git rev-parse --short HEAD) 3 | BUILD_LDFLAGS = "-X github.com/Songmu/timeout.revision=$(CURRENT_REVISION)" 4 | ifdef update 5 | u=-u 6 | endif 7 | 8 | export GO111MODULE=on 9 | 10 | .PHONY: deps 11 | deps: 12 | go get ${u} -d -v 13 | 14 | .PHONY: devel-deps 15 | devel-deps: deps 16 | GO111MODULE=off go get ${u} \ 17 | golang.org/x/lint/golint \ 18 | github.com/mattn/goveralls \ 19 | github.com/Songmu/goxz/cmd/goxz \ 20 | github.com/Songmu/gocredits/cmd/gocredits \ 21 | github.com/Songmu/godzil/cmd/godzil \ 22 | github.com/tcnksm/ghr 23 | 24 | .PHONY: test 25 | test: deps 26 | go test 27 | 28 | .PHONY: lint 29 | lint: devel-deps 30 | go vet 31 | golint -set_exit_status 32 | 33 | .PHONY: cover 34 | cover: devel-deps 35 | goveralls 36 | 37 | .PHONY: build 38 | build: deps 39 | go build -ldflags=$(BUILD_LDFLAGS) ./cmd/goxz 40 | 41 | .PHONY: install 42 | install: build 43 | mv go-timeout "$(shell go env GOPATH)/bin" 44 | 45 | .PHONY: bump 46 | bump: devel-deps 47 | godzil release 48 | 49 | CREDITS: devel-deps go.sum 50 | gocredits -w 51 | 52 | .PHONY: crossbuild 53 | crossbuild: CREDITS 54 | goxz -pv=v$(VERSION) -build-ldflags=$(BUILD_LDFLAGS) \ 55 | -d=./dist/v$(VERSION) ./cmd/go-timeout 56 | 57 | .PHONY: upload 58 | upload: 59 | ghr v$(VERSION) dist/v$(VERSION) 60 | 61 | .PHONY: release 62 | release: bump crossbuild upload 63 | -------------------------------------------------------------------------------- /timeout_unix_test.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package timeout 4 | 5 | import ( 6 | "os/exec" 7 | "syscall" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestRunSimple_withStop(t *testing.T) { 13 | tio := &Timeout{ 14 | Duration: 100 * time.Microsecond, 15 | KillAfter: 1 * time.Second, 16 | Cmd: exec.Command(shellcmd, shellflag, "sleep 10"), 17 | } 18 | ch, err := tio.RunCommand() 19 | if err != nil { 20 | t.Errorf("err should be nil but: %s", err) 21 | } 22 | tio.Cmd.Process.Signal(syscall.SIGSTOP) 23 | st := <-ch 24 | 25 | expect := 128 + int(syscall.SIGTERM) 26 | if st.Code != expect { 27 | t.Errorf("exit code invalid. out: %d, expect: %d", st.Code, expect) 28 | } 29 | } 30 | 31 | func TestRunCommand_signaled(t *testing.T) { 32 | testCases := []struct { 33 | name string 34 | cmd *exec.Cmd 35 | exit int 36 | signaled bool 37 | }{ 38 | { 39 | name: "signal handled", 40 | cmd: exec.Command(stubCmd, "-trap", "SIGTERM", "-trap-exit", "23", "-sleep", "3"), 41 | exit: 23, 42 | signaled: false, 43 | }, 44 | { 45 | name: "termed by sigterm", 46 | cmd: exec.Command("sleep", "1"), 47 | exit: 128 + int(syscall.SIGTERM), 48 | signaled: true, 49 | }, 50 | } 51 | 52 | for _, tc := range testCases { 53 | t.Run(tc.name, func(t *testing.T) { 54 | tio := &Timeout{ 55 | Duration: 100 * time.Millisecond, 56 | KillAfter: 3 * time.Second, 57 | Cmd: tc.cmd, 58 | } 59 | st, _, _, err := tio.Run() 60 | 61 | if err != nil { 62 | t.Errorf("error should be nil but: %s", err) 63 | } 64 | 65 | if st.GetChildExitCode() != tc.exit { 66 | t.Errorf("expected exitcode: %d, but: %d", tc.exit, st.GetChildExitCode()) 67 | } 68 | if st.Signaled != tc.signaled { 69 | t.Errorf("something went wrong") 70 | } 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /cmd/go-timeout/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "regexp" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/Songmu/timeout" 12 | "github.com/pborman/getopt" 13 | ) 14 | 15 | func main() { 16 | optKillAfter := getopt.StringLong("kill-after", 'k', "", "also send a KILL signal if COMMAND is still running. this long after the initial signal was sent") 17 | optSig := getopt.StringLong("signal", 's', "", "specify the signal to be sent on timeout. IGNAL may be a name like 'HUP' or a number. see 'kill -l' for a list of signals") 18 | optForeground := getopt.BoolLong("foreground", 0, "when not running timeout directly from a shell prompt, allow COMMAND to read from the TTY and get TTY signals. in this mode, children of COMMAND will not be timed out") 19 | p := getopt.BoolLong("preserve-status", 0, "exit with the same status as COMMAND, even when the command times out") 20 | 21 | opts := getopt.CommandLine 22 | opts.Parse(os.Args) 23 | 24 | rest := opts.Args() 25 | if len(rest) < 2 { 26 | opts.PrintUsage(os.Stderr) 27 | os.Exit(1) 28 | } 29 | 30 | var err error 31 | killAfter := float64(0) 32 | if *optKillAfter != "" { 33 | killAfter, err = parseDuration(*optKillAfter) 34 | if err != nil { 35 | fmt.Fprintln(os.Stderr, err.Error()) 36 | os.Exit(125) 37 | } 38 | } 39 | 40 | var sig os.Signal 41 | if *optSig != "" { 42 | sig, err = parseSignal(*optSig) 43 | if err != nil { 44 | fmt.Fprintln(os.Stderr, err.Error()) 45 | os.Exit(125) 46 | } 47 | } 48 | 49 | dur, err := parseDuration(rest[0]) 50 | if err != nil { 51 | fmt.Fprintln(os.Stderr, err.Error()) 52 | os.Exit(125) 53 | } 54 | 55 | cmd := exec.Command(rest[1], rest[2:]...) 56 | 57 | tio := &timeout.Timeout{ 58 | Duration: time.Duration(dur * float64(time.Second)), 59 | Cmd: cmd, 60 | Foreground: *optForeground, 61 | KillAfter: time.Duration(killAfter * float64(time.Second)), 62 | Signal: sig, 63 | } 64 | exit := tio.RunSimple(*p) 65 | os.Exit(exit) 66 | } 67 | 68 | var durRe = regexp.MustCompile(`^([-0-9e.]+)([smhd])?$`) 69 | 70 | func parseDuration(durStr string) (float64, error) { 71 | matches := durRe.FindStringSubmatch(durStr) 72 | if len(matches) == 0 { 73 | return 0, fmt.Errorf("duration format invalid: %s", durStr) 74 | } 75 | 76 | base, err := strconv.ParseFloat(matches[1], 64) 77 | if err != nil { 78 | return 0, fmt.Errorf("invalid time interval `%s`", durStr) 79 | } 80 | switch matches[2] { 81 | case "", "s": 82 | return base, nil 83 | case "m": 84 | return base * 60, nil 85 | case "h": 86 | return base * 60 * 60, nil 87 | case "d": 88 | return base * 60 * 60 * 24, nil 89 | default: 90 | return 0, fmt.Errorf("invalid time interval `%s`", durStr) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v0.4.0](https://github.com/Songmu/timeout/compare/v0.3.1...v0.4.0) (2019-04-21) 4 | 5 | * introduce Go Modules [#21](https://github.com/Songmu/timeout/pull/21) ([Songmu](https://github.com/Songmu)) 6 | 7 | ## [v0.3.1](https://github.com/Songmu/timeout/compare/v0.3.0...v0.3.1) (2018-03-04) 8 | 9 | * add rough test to detect goroutine leak [#20](https://github.com/Songmu/timeout/pull/20) ([Songmu](https://github.com/Songmu)) 10 | * fix goroutine leak [#19](https://github.com/Songmu/timeout/pull/19) ([Songmu](https://github.com/Songmu)) 11 | 12 | ## [v0.3.0](https://github.com/Songmu/timeout/compare/v0.2.1...v0.3.0) (2018-02-14) 13 | 14 | * Context support with RunContext method [#16](https://github.com/Songmu/timeout/pull/16) ([Songmu](https://github.com/Songmu)) 15 | * [incompatible] use pointer when returning ExitStatus [#17](https://github.com/Songmu/timeout/pull/17) ([Songmu](https://github.com/Songmu)) 16 | * test for signaled [#15](https://github.com/Songmu/timeout/pull/15) ([Songmu](https://github.com/Songmu)) 17 | 18 | ## [v0.2.1](https://github.com/Songmu/timeout/compare/v0.2.0...v0.2.1) (2018-01-07) 19 | 20 | * send SIGCONT after sending termination signal just to make sure [#14](https://github.com/Songmu/timeout/pull/14) ([Songmu](https://github.com/Songmu)) 21 | * remove reflect and refactor [#13](https://github.com/Songmu/timeout/pull/13) ([Songmu](https://github.com/Songmu)) 22 | 23 | ## [v0.2.0](https://github.com/Songmu/timeout/compare/v0.1.0...v0.2.0) (2018-01-07) 24 | 25 | * Adjust files for releasing [#12](https://github.com/Songmu/timeout/pull/12) ([Songmu](https://github.com/Songmu)) 26 | * adjust testing(introduce table driven test) [#11](https://github.com/Songmu/timeout/pull/11) ([Songmu](https://github.com/Songmu)) 27 | * Wait for the command to finish properly and add Signaled field to ExitStatus [#10](https://github.com/Songmu/timeout/pull/10) ([Songmu](https://github.com/Songmu)) 28 | * introduce github.com/Songmu/wrapcommander [#9](https://github.com/Songmu/timeout/pull/9) ([Songmu](https://github.com/Songmu)) 29 | * update doc [#8](https://github.com/Songmu/timeout/pull/8) ([Songmu](https://github.com/Songmu)) 30 | 31 | ## [v0.1.0](https://github.com/Songmu/timeout/compare/v0.0.1...v0.1.0) (2017-03-26) 32 | 33 | * [incompatible] Support Foreground option [#6](https://github.com/Songmu/timeout/pull/6) ([Songmu](https://github.com/Songmu)) 34 | * [incompatible] killall child processes when sending SIGKILL on Unix systems [#5](https://github.com/Songmu/timeout/pull/5) ([Songmu](https://github.com/Songmu)) 35 | * Call taskkill [#3](https://github.com/Songmu/timeout/pull/3) ([mattn](https://github.com/mattn)) 36 | * update ci related files [#4](https://github.com/Songmu/timeout/pull/4) ([Songmu](https://github.com/Songmu)) 37 | 38 | ## [v0.0.1](https://github.com/Songmu/timeout/compare/fca682e36f92...v0.0.1) (2015-04-23) 39 | 40 | * Fix document [#2](https://github.com/Songmu/timeout/pull/2) ([syohex](https://github.com/syohex)) 41 | * Support windows [#1](https://github.com/Songmu/timeout/pull/1) ([mattn](https://github.com/mattn)) 42 | -------------------------------------------------------------------------------- /CREDITS: -------------------------------------------------------------------------------- 1 | Go (the standard library) 2 | https://golang.org/ 3 | ---------------------------------------------------------------- 4 | Copyright (c) 2009 The Go Authors. All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are 8 | met: 9 | 10 | * Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | * Redistributions in binary form must reproduce the above 13 | copyright notice, this list of conditions and the following disclaimer 14 | in the documentation and/or other materials provided with the 15 | distribution. 16 | * Neither the name of Google Inc. nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | ================================================================ 33 | 34 | github.com/Songmu/wrapcommander 35 | https://github.com/Songmu/wrapcommander 36 | ---------------------------------------------------------------- 37 | Copyright (c) 2015 Songmu 38 | 39 | MIT License 40 | 41 | Permission is hereby granted, free of charge, to any person obtaining 42 | a copy of this software and associated documentation files (the 43 | "Software"), to deal in the Software without restriction, including 44 | without limitation the rights to use, copy, modify, merge, publish, 45 | distribute, sublicense, and/or sell copies of the Software, and to 46 | permit persons to whom the Software is furnished to do so, subject to 47 | the following conditions: 48 | 49 | The above copyright notice and this permission notice shall be 50 | included in all copies or substantial portions of the Software. 51 | 52 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 53 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 54 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 55 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 56 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 57 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 58 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 59 | 60 | ================================================================ 61 | 62 | -------------------------------------------------------------------------------- /timeout.go: -------------------------------------------------------------------------------- 1 | // Package timeout is for handling timeout invocation of external command 2 | package timeout 3 | 4 | import ( 5 | "bytes" 6 | "context" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/Songmu/wrapcommander" 14 | ) 15 | 16 | // exit statuses are same with GNU timeout 17 | const ( 18 | exitNormal = 0 19 | exitTimedOut = 124 20 | exitUnknownErr = 125 21 | exitKilled = 137 22 | ) 23 | 24 | // overwritten with syscall.SIGTERM on unix environment (see timeout_unix.go) 25 | var defaultSignal = os.Interrupt 26 | 27 | // Error is error of timeout 28 | type Error struct { 29 | ExitCode int 30 | Err error 31 | } 32 | 33 | func (err *Error) Error() string { 34 | return fmt.Sprintf("exit code: %d, %s", err.ExitCode, err.Err.Error()) 35 | } 36 | 37 | // Timeout is main struct of timeout package 38 | type Timeout struct { 39 | Duration time.Duration 40 | KillAfter time.Duration 41 | Signal os.Signal 42 | Foreground bool 43 | Cmd *exec.Cmd 44 | 45 | KillAfterCancel time.Duration 46 | } 47 | 48 | func (tio *Timeout) signal() os.Signal { 49 | if tio.Signal == nil { 50 | return defaultSignal 51 | } 52 | return tio.Signal 53 | } 54 | 55 | // Run is synchronous interface of executing command and returning information 56 | func (tio *Timeout) Run() (*ExitStatus, string, string, error) { 57 | cmd := tio.getCmd() 58 | var outBuffer, errBuffer bytes.Buffer 59 | cmd.Stdout = &outBuffer 60 | cmd.Stderr = &errBuffer 61 | 62 | ch, err := tio.RunCommand() 63 | if err != nil { 64 | fmt.Fprintln(os.Stderr, err) 65 | return nil, string(outBuffer.Bytes()), string(errBuffer.Bytes()), err 66 | } 67 | exitSt := <-ch 68 | return exitSt, string(outBuffer.Bytes()), string(errBuffer.Bytes()), nil 69 | } 70 | 71 | // RunSimple executes command and only returns integer as exit code. It is mainly for go-timeout command 72 | func (tio *Timeout) RunSimple(preserveStatus bool) int { 73 | cmd := tio.getCmd() 74 | cmd.Stdout = os.Stdout 75 | cmd.Stderr = os.Stderr 76 | 77 | ch, err := tio.RunCommand() 78 | if err != nil { 79 | fmt.Fprintln(os.Stderr, err) 80 | return getExitCodeFromErr(err) 81 | } 82 | 83 | exitSt := <-ch 84 | if preserveStatus { 85 | return exitSt.GetChildExitCode() 86 | } 87 | return exitSt.GetExitCode() 88 | } 89 | 90 | func getExitCodeFromErr(err error) int { 91 | if err != nil { 92 | if tmerr, ok := err.(*Error); ok { 93 | return tmerr.ExitCode 94 | } 95 | return -1 96 | } 97 | return exitNormal 98 | } 99 | 100 | // RunContext runs command with context 101 | func (tio *Timeout) RunContext(ctx context.Context) (*ExitStatus, error) { 102 | if err := tio.start(); err != nil { 103 | return nil, err 104 | } 105 | return tio.wait(ctx), nil 106 | } 107 | 108 | // RunCommand is executing the command and handling timeout. This is primitive interface of Timeout 109 | func (tio *Timeout) RunCommand() (<-chan *ExitStatus, error) { 110 | if err := tio.start(); err != nil { 111 | return nil, err 112 | } 113 | 114 | exitChan := make(chan *ExitStatus) 115 | go func() { 116 | exitChan <- tio.wait(context.Background()) 117 | }() 118 | return exitChan, nil 119 | } 120 | 121 | func (tio *Timeout) start() error { 122 | if err := tio.getCmd().Start(); err != nil { 123 | return &Error{ 124 | ExitCode: wrapcommander.ResolveExitCode(err), 125 | Err: err, 126 | } 127 | } 128 | return nil 129 | } 130 | 131 | func (tio *Timeout) wait(ctx context.Context) *ExitStatus { 132 | ex := &ExitStatus{} 133 | cmd := tio.getCmd() 134 | exitChan := getExitChan(cmd) 135 | killCh := make(chan struct{}, 2) 136 | done := make(chan struct{}) 137 | defer close(done) 138 | 139 | delayedKill := func(dur time.Duration) { 140 | select { 141 | case <-done: 142 | return 143 | case <-time.After(dur): 144 | killCh <- struct{}{} 145 | } 146 | } 147 | 148 | if tio.KillAfter > 0 { 149 | go delayedKill(tio.Duration + tio.KillAfter) 150 | } 151 | for { 152 | select { 153 | case st := <-exitChan: 154 | ex.Code = wrapcommander.WaitStatusToExitCode(st) 155 | ex.Signaled = st.Signaled() 156 | return ex 157 | case <-time.After(tio.Duration): 158 | tio.terminate() 159 | ex.typ = exitTypeTimedOut 160 | case <-killCh: 161 | tio.killall() 162 | // just to make sure 163 | cmd.Process.Kill() 164 | ex.killed = true 165 | if ex.typ != exitTypeCanceled { 166 | ex.typ = exitTypeKilled 167 | } 168 | case <-ctx.Done(): 169 | // XXX handling etx.Err()? 170 | tio.terminate() 171 | ex.typ = exitTypeCanceled 172 | go delayedKill(tio.getKillAfterCancel()) 173 | } 174 | } 175 | } 176 | 177 | func (tio *Timeout) getKillAfterCancel() time.Duration { 178 | if tio.KillAfterCancel == 0 { 179 | return 3 * time.Second 180 | } 181 | return tio.KillAfterCancel 182 | } 183 | 184 | func getExitChan(cmd *exec.Cmd) chan syscall.WaitStatus { 185 | ch := make(chan syscall.WaitStatus) 186 | go func() { 187 | err := cmd.Wait() 188 | st, _ := wrapcommander.ErrorToWaitStatus(err) 189 | ch <- st 190 | }() 191 | return ch 192 | } 193 | -------------------------------------------------------------------------------- /timeout_test.go: -------------------------------------------------------------------------------- 1 | package timeout 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "reflect" 9 | "runtime" 10 | "strings" 11 | "syscall" 12 | "testing" 13 | "time" 14 | ) 15 | 16 | var ( 17 | shellcmd = "/bin/sh" 18 | shellflag = "-c" 19 | stubCmd = "./testdata/stubcmd" 20 | ) 21 | 22 | func init() { 23 | if runtime.GOOS == "windows" { 24 | shellcmd = "cmd" 25 | shellflag = "/c" 26 | stubCmd = `.\testdata\stubcmd.exe` 27 | } 28 | err := exec.Command("go", "build", "-o", stubCmd, "testdata/stubcmd.go").Run() 29 | if err != nil { 30 | panic(err) 31 | } 32 | } 33 | 34 | func TestRun(t *testing.T) { 35 | tio := &Timeout{ 36 | Duration: 10 * time.Second, 37 | Cmd: exec.Command(shellcmd, shellflag, "echo 1"), 38 | } 39 | _, stdout, stderr, err := tio.Run() 40 | 41 | if strings.TrimSpace(stdout) != "1" { 42 | t.Errorf("something wrong") 43 | } 44 | 45 | if stderr != "" { 46 | t.Errorf("something wrong") 47 | } 48 | 49 | if err != nil { 50 | t.Errorf("something wrong: %v", err) 51 | } 52 | } 53 | 54 | var isWin = runtime.GOOS == "windows" 55 | 56 | func TestRunSimple(t *testing.T) { 57 | testCases := []struct { 58 | name string 59 | duration time.Duration 60 | killAfter time.Duration 61 | cmd *exec.Cmd 62 | signal os.Signal 63 | preserveStatus bool 64 | expectedExit int 65 | skipOnWin bool 66 | }{ 67 | { 68 | name: "simple echo", 69 | duration: time.Duration(0.1 * float64(time.Second)), 70 | cmd: exec.Command(shellcmd, shellflag, "echo 1"), 71 | expectedExit: 0, 72 | }, 73 | { 74 | name: "timed out", 75 | cmd: exec.Command(shellcmd, shellflag, fmt.Sprintf("%s -sleep 3", stubCmd)), 76 | duration: 100 * time.Millisecond, 77 | signal: os.Interrupt, 78 | expectedExit: 124, 79 | }, 80 | { 81 | name: "timed out with preserve status", 82 | cmd: exec.Command(shellcmd, shellflag, fmt.Sprintf("%s -sleep 3", stubCmd)), 83 | duration: time.Duration(0.1 * float64(time.Second)), 84 | preserveStatus: true, 85 | expectedExit: 128 + int(syscall.SIGTERM), 86 | skipOnWin: true, 87 | }, 88 | { 89 | name: "preserve status (signal trapd)", 90 | cmd: exec.Command(stubCmd, "-trap", "SIGTERM", "-trap-exit", "23", "-sleep", "3"), 91 | duration: 100 * time.Millisecond, 92 | preserveStatus: true, 93 | expectedExit: 23, 94 | skipOnWin: true, 95 | }, 96 | { 97 | name: "kill after", 98 | cmd: exec.Command(stubCmd, "-trap", "SIGTERM", "-sleep", "3"), 99 | duration: 100 * time.Millisecond, 100 | killAfter: 100 * time.Microsecond, 101 | signal: syscall.SIGTERM, 102 | expectedExit: exitKilled, 103 | }, 104 | { 105 | name: "trap sigterm but exited before kill after", 106 | cmd: exec.Command(stubCmd, "-trap", "SIGTERM", "-sleep", "0.8"), 107 | duration: 100 * time.Millisecond, 108 | killAfter: 5 * time.Second, 109 | signal: syscall.SIGTERM, 110 | preserveStatus: true, 111 | expectedExit: 0, 112 | }, 113 | { 114 | name: "command cannnot be invoked", 115 | cmd: exec.Command("testdata/dummy"), 116 | duration: 1 * time.Second, 117 | expectedExit: 126, // TODO cmd should return 125 on win 118 | skipOnWin: true, 119 | }, 120 | { 121 | name: "command cannnot be invoked (command not found)", 122 | cmd: exec.Command("testdata/command-not-found"), 123 | duration: 1 * time.Second, 124 | expectedExit: 127, // TODO cmd should return 125 on win 125 | skipOnWin: true, 126 | }, 127 | } 128 | 129 | for _, tc := range testCases { 130 | t.Run(tc.name, func(t *testing.T) { 131 | if tc.skipOnWin && isWin { 132 | t.Skipf("%s: skip on windows", tc.name) 133 | } 134 | tio := &Timeout{ 135 | Duration: tc.duration, 136 | KillAfter: tc.killAfter, 137 | Cmd: tc.cmd, 138 | Signal: tc.signal, 139 | } 140 | exit := tio.RunSimple(tc.preserveStatus) 141 | if exit != tc.expectedExit { 142 | t.Errorf("%s: expected exitcode: %d, but: %d", tc.name, tc.expectedExit, exit) 143 | } 144 | }) 145 | } 146 | } 147 | 148 | func TestRunContext(t *testing.T) { 149 | expect := ExitStatus{ 150 | Code: 128 + int(syscall.SIGTERM), 151 | Signaled: true, 152 | typ: exitTypeCanceled, 153 | killed: false, 154 | } 155 | if isWin { 156 | expect = ExitStatus{ 157 | Code: 1, 158 | Signaled: false, 159 | typ: exitTypeCanceled, 160 | killed: true, 161 | } 162 | } 163 | 164 | t.Run("cancel", func(t *testing.T) { 165 | tio := &Timeout{ 166 | Duration: 3 * time.Second, 167 | Cmd: exec.Command(stubCmd, "-sleep", "10"), 168 | } 169 | ctx, cancel := context.WithCancel(context.Background()) 170 | go func() { 171 | time.Sleep(100 * time.Millisecond) 172 | cancel() 173 | }() 174 | st, err := tio.RunContext(ctx) 175 | if err != nil { 176 | t.Errorf("error should be nil but: %s", err) 177 | } 178 | if !reflect.DeepEqual(expect, *st) { 179 | t.Errorf("invalid exit status\n out: %v\nexpect: %v", *st, expect) 180 | } 181 | }) 182 | 183 | t.Run("with timeout", func(t *testing.T) { 184 | tio := &Timeout{ 185 | Duration: 3 * time.Second, 186 | Cmd: exec.Command(stubCmd, "-sleep", "10"), 187 | } 188 | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) 189 | defer cancel() 190 | st, err := tio.RunContext(ctx) 191 | if err != nil { 192 | t.Errorf("error should be nil but: %s", err) 193 | } 194 | if !reflect.DeepEqual(expect, *st) { 195 | t.Errorf("invalid exit status\n out: %v\nexpect: %v", *st, expect) 196 | } 197 | }) 198 | 199 | t.Run("with timeout and signal trapped", func(t *testing.T) { 200 | if isWin { 201 | t.Skip("skip on windows") 202 | } 203 | tio := &Timeout{ 204 | Duration: 3 * time.Second, 205 | Cmd: exec.Command(stubCmd, "-sleep", "10", "-trap", "SIGTERM"), 206 | KillAfterCancel: time.Duration(10 * time.Millisecond), 207 | } 208 | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) 209 | defer cancel() 210 | st, err := tio.RunContext(ctx) 211 | if err != nil { 212 | t.Errorf("error should be nil but: %s", err) 213 | } 214 | expect := ExitStatus{ 215 | Code: exitKilled, 216 | Signaled: true, 217 | typ: exitTypeCanceled, 218 | killed: true, 219 | } 220 | if !reflect.DeepEqual(expect, *st) { 221 | t.Errorf("invalid exit status\n out: %v\nexpect: %v", *st, expect) 222 | } 223 | }) 224 | } 225 | 226 | func TestRun_leak(t *testing.T) { 227 | beforeGoroutine := runtime.NumGoroutine() 228 | for range make([]struct{}, 30) { 229 | tio := &Timeout{ 230 | Cmd: exec.Command(stubCmd, "-sleep=0.1"), 231 | Duration: time.Second, 232 | KillAfter: time.Second, 233 | } 234 | go func() { 235 | tio.Run() 236 | }() 237 | } 238 | time.Sleep(time.Second) 239 | afterGoroutine := runtime.NumGoroutine() 240 | 241 | if beforeGoroutine+10 < afterGoroutine { 242 | t.Errorf("goroutine may be leaked. before: %d, after: %d", beforeGoroutine, afterGoroutine) 243 | } 244 | } 245 | --------------------------------------------------------------------------------