Skip to content

Commit 9957a52

Browse files
FiloSottilemvdan
authored andcommitted
testscript: import timeout behavior from stdlib
This uses the -test.timeout flag (or the Params.Deadline field) to send first SIGQUIT (to get a stack trace) and then SIGKILL to a stuck command.
1 parent 6ac6b82 commit 9957a52

File tree

2 files changed

+111
-18
lines changed

2 files changed

+111
-18
lines changed

testscript/cmd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ func (ts *TestScript) cmdExec(neg bool, args []string) {
253253
if err == nil {
254254
wait := make(chan struct{})
255255
go func() {
256-
ctxWait(ts.ctxt, cmd)
256+
waitOrStop(ts.ctxt, cmd, -1)
257257
close(wait)
258258
}()
259259
ts.background = append(ts.background, backgroundCmd{bgName, cmd, wait, neg})

testscript/testscript.go

Lines changed: 110 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"runtime"
2323
"strings"
2424
"sync/atomic"
25+
"syscall"
2526
"testing"
2627
"time"
2728

@@ -177,11 +178,19 @@ type Params struct {
177178
// the face of errors. Once an error has occurred, the script
178179
// will continue as if in verbose mode.
179180
ContinueOnError bool
181+
182+
// Deadline, if not zero, specifies the time at which the test run will have
183+
// exceeded the timeout. It is equivalent to testing.T's Deadline method,
184+
// and Run will set it to the method's return value if this field is zero.
185+
Deadline time.Time
180186
}
181187

182188
// RunDir runs the tests in the given directory. All files in dir with a ".txt"
183189
// or ".txtar" extension are considered to be test files.
184190
func Run(t *testing.T, p Params) {
191+
if deadline, ok := t.Deadline(); ok && p.Deadline.IsZero() {
192+
p.Deadline = deadline
193+
}
185194
RunT(tshim{t}, p)
186195
}
187196

@@ -256,6 +265,37 @@ func RunT(t T, p Params) {
256265
if err != nil {
257266
t.Fatal(err)
258267
}
268+
269+
var (
270+
ctx = context.Background()
271+
gracePeriod = 100 * time.Millisecond
272+
cancel context.CancelFunc
273+
)
274+
if !p.Deadline.IsZero() {
275+
timeout := time.Until(p.Deadline)
276+
277+
// If time allows, increase the termination grace period to 5% of the
278+
// remaining time.
279+
if gp := timeout / 20; gp > gracePeriod {
280+
gracePeriod = gp
281+
}
282+
283+
// When we run commands that execute subprocesses, we want to reserve two
284+
// grace periods to clean up. We will send the first termination signal when
285+
// the context expires, then wait one grace period for the process to
286+
// produce whatever useful output it can (such as a stack trace). After the
287+
// first grace period expires, we'll escalate to os.Kill, leaving the second
288+
// grace period for the test function to record its output before the test
289+
// process itself terminates.
290+
timeout -= 2 * gracePeriod
291+
292+
ctx, cancel = context.WithTimeout(ctx, timeout)
293+
// We don't defer cancel() because RunT returns before the sub-tests,
294+
// and we don't have access to Cleanup due to the T interface. Instead,
295+
// we call it after the refCount goes to zero below.
296+
_ = cancel
297+
}
298+
259299
refCount := int32(len(files))
260300
for _, file := range files {
261301
file := file
@@ -269,7 +309,8 @@ func RunT(t T, p Params) {
269309
name: name,
270310
file: file,
271311
params: p,
272-
ctxt: context.Background(),
312+
ctxt: ctx,
313+
gracePeriod: gracePeriod,
273314
deferred: func() {},
274315
scriptFiles: make(map[string]string),
275316
scriptUpdates: make(map[string]string),
@@ -281,8 +322,11 @@ func RunT(t T, p Params) {
281322
removeAll(ts.workdir)
282323
if atomic.AddInt32(&refCount, -1) == 0 {
283324
// This is the last subtest to finish. Remove the
284-
// parent directory too.
325+
// parent directory too, and cancel the context.
285326
os.Remove(testTempDir)
327+
if cancel != nil {
328+
cancel()
329+
}
286330
}
287331
}()
288332
ts.run()
@@ -317,7 +361,8 @@ type TestScript struct {
317361
scriptFiles map[string]string // files stored in the txtar archive (absolute paths -> path in script)
318362
scriptUpdates map[string]string // updates to testscript files via UpdateScripts.
319363

320-
ctxt context.Context // per TestScript context
364+
ctxt context.Context // per TestScript context
365+
gracePeriod time.Duration // time between SIGQUIT and SIGKILL
321366
}
322367

323368
type backgroundCmd struct {
@@ -346,6 +391,7 @@ func (ts *TestScript) setup() string {
346391
Vars: []string{
347392
"WORK=" + ts.workdir, // must be first for ts.abbrev
348393
"PATH=" + os.Getenv("PATH"),
394+
"GOTRACEBACK=system",
349395
homeEnvName() + "=/no-home",
350396
tempEnvName() + "=" + tmpDir,
351397
"devnull=" + os.DevNull,
@@ -729,7 +775,7 @@ func (ts *TestScript) exec(command string, args ...string) (stdout, stderr strin
729775
cmd.Stdout = &stdoutBuf
730776
cmd.Stderr = &stderrBuf
731777
if err = cmd.Start(); err == nil {
732-
err = ctxWait(ts.ctxt, cmd)
778+
err = waitOrStop(ts.ctxt, cmd, ts.gracePeriod)
733779
}
734780
ts.stdin = ""
735781
return stdoutBuf.String(), stderrBuf.String(), err
@@ -774,21 +820,68 @@ func (ts *TestScript) BackgroundCmds() []*exec.Cmd {
774820
return cmds
775821
}
776822

777-
// ctxWait is like cmd.Wait, but terminates cmd with os.Interrupt if ctx becomes done.
823+
// waitOrStop waits for the already-started command cmd by calling its Wait method.
778824
//
779-
// This differs from exec.CommandContext in that it prefers os.Interrupt over os.Kill.
780-
// (See https://golang.org/issue/21135.)
781-
func ctxWait(ctx context.Context, cmd *exec.Cmd) error {
782-
errc := make(chan error, 1)
783-
go func() { errc <- cmd.Wait() }()
784-
785-
select {
786-
case err := <-errc:
787-
return err
788-
case <-ctx.Done():
789-
interruptProcess(cmd.Process)
790-
return <-errc
825+
// If cmd does not return before ctx is done, waitOrStop sends it an interrupt
826+
// signal. If killDelay is positive, waitOrStop waits that additional period for
827+
// Wait to return before sending os.Kill.
828+
func waitOrStop(ctx context.Context, cmd *exec.Cmd, killDelay time.Duration) error {
829+
if cmd.Process == nil {
830+
panic("waitOrStop called with a nil cmd.Process — missing Start call?")
831+
}
832+
833+
errc := make(chan error)
834+
go func() {
835+
select {
836+
case errc <- nil:
837+
return
838+
case <-ctx.Done():
839+
}
840+
841+
var interrupt os.Signal = syscall.SIGQUIT
842+
if runtime.GOOS == "windows" {
843+
// Per https://golang.org/pkg/os/#Signal, “Interrupt is not implemented on
844+
// Windows; using it with os.Process.Signal will return an error.”
845+
// Fall back directly to Kill instead.
846+
interrupt = os.Kill
847+
}
848+
849+
err := cmd.Process.Signal(interrupt)
850+
if err == nil {
851+
err = ctx.Err() // Report ctx.Err() as the reason we interrupted.
852+
} else if err == os.ErrProcessDone {
853+
errc <- nil
854+
return
855+
}
856+
857+
if killDelay > 0 {
858+
timer := time.NewTimer(killDelay)
859+
select {
860+
// Report ctx.Err() as the reason we interrupted the process...
861+
case errc <- ctx.Err():
862+
timer.Stop()
863+
return
864+
// ...but after killDelay has elapsed, fall back to a stronger signal.
865+
case <-timer.C:
866+
}
867+
868+
// Wait still hasn't returned.
869+
// Kill the process harder to make sure that it exits.
870+
//
871+
// Ignore any error: if cmd.Process has already terminated, we still
872+
// want to send ctx.Err() (or the error from the Interrupt call)
873+
// to properly attribute the signal that may have terminated it.
874+
_ = cmd.Process.Kill()
875+
}
876+
877+
errc <- err
878+
}()
879+
880+
waitErr := cmd.Wait()
881+
if interruptErr := <-errc; interruptErr != nil {
882+
return interruptErr
791883
}
884+
return waitErr
792885
}
793886

794887
// interruptProcess sends os.Interrupt to p if supported, or os.Kill otherwise.

0 commit comments

Comments
 (0)