Skip to content

Commit caf8fb1

Browse files
Bryan C. Millsgopherbot
Bryan C. Mills
authored andcommitted
cmd/go: use Cancel and WaitDelay to terminate test subprocesses
Updates #50436. Updates #56163. Fixes #24050. Change-Id: I1b00eb8fb60e0879f029642b5bad97b2e139fee6 Reviewed-on: https://go-review.googlesource.com/c/go/+/456116 Reviewed-by: Ian Lance Taylor <[email protected]> TryBot-Result: Gopher Robot <[email protected]> Run-TryBot: Bryan Mills <[email protected]> Auto-Submit: Bryan Mills <[email protected]>
1 parent e49cb02 commit caf8fb1

File tree

2 files changed

+125
-35
lines changed

2 files changed

+125
-35
lines changed

src/cmd/go/internal/test/test.go

+37-35
Original file line numberDiff line numberDiff line change
@@ -1276,7 +1276,12 @@ func (r *runTestActor) Act(b *work.Builder, ctx context.Context, a *work.Action)
12761276
}
12771277
}
12781278

1279-
cmd := exec.Command(args[0], args[1:]...)
1279+
// Normally, the test will terminate itself when the timeout expires,
1280+
// but add a last-ditch deadline to detect and stop wedged binaries.
1281+
ctx, cancel := context.WithTimeout(ctx, testKillTimeout)
1282+
defer cancel()
1283+
1284+
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
12801285
cmd.Dir = a.Package.Dir
12811286

12821287
env := cfg.OrigEnv[:len(cfg.OrigEnv):len(cfg.OrigEnv)]
@@ -1309,42 +1314,33 @@ func (r *runTestActor) Act(b *work.Builder, ctx context.Context, a *work.Action)
13091314
cmd.Env = env
13101315
}
13111316

1312-
base.StartSigHandlers()
1313-
t0 := time.Now()
1314-
err = cmd.Start()
1315-
1316-
// This is a last-ditch deadline to detect and
1317-
// stop wedged test binaries, to keep the builders
1318-
// running.
1319-
if err == nil {
1320-
tick := time.NewTimer(testKillTimeout)
1321-
done := make(chan error)
1322-
go func() {
1323-
done <- cmd.Wait()
1324-
}()
1325-
Outer:
1326-
select {
1327-
case err = <-done:
1328-
// ok
1329-
case <-tick.C:
1330-
if base.SignalTrace != nil {
1331-
// Send a quit signal in the hope that the program will print
1332-
// a stack trace and exit. Give it five seconds before resorting
1333-
// to Kill.
1334-
cmd.Process.Signal(base.SignalTrace)
1335-
select {
1336-
case err = <-done:
1337-
fmt.Fprintf(cmd.Stdout, "*** Test killed with %v: ran too long (%v).\n", base.SignalTrace, testKillTimeout)
1338-
break Outer
1339-
case <-time.After(5 * time.Second):
1340-
}
1317+
var (
1318+
cancelKilled = false
1319+
cancelSignaled = false
1320+
)
1321+
cmd.Cancel = func() error {
1322+
if base.SignalTrace == nil {
1323+
err := cmd.Process.Kill()
1324+
if err == nil {
1325+
cancelKilled = true
13411326
}
1342-
cmd.Process.Kill()
1343-
err = <-done
1344-
fmt.Fprintf(cmd.Stdout, "*** Test killed: ran too long (%v).\n", testKillTimeout)
1327+
return err
13451328
}
1346-
tick.Stop()
1329+
1330+
// Send a quit signal in the hope that the program will print
1331+
// a stack trace and exit.
1332+
err := cmd.Process.Signal(base.SignalTrace)
1333+
if err == nil {
1334+
cancelSignaled = true
1335+
}
1336+
return err
13471337
}
1338+
// Give the test five seconds to exit after the signal before resorting to Kill.
1339+
cmd.WaitDelay = 5 * time.Second
1340+
1341+
base.StartSigHandlers()
1342+
t0 := time.Now()
1343+
err = cmd.Run()
13481344
out := buf.Bytes()
13491345
a.TestOutput = &buf
13501346
t := fmt.Sprintf("%.3fs", time.Since(t0).Seconds())
@@ -1374,7 +1370,13 @@ func (r *runTestActor) Act(b *work.Builder, ctx context.Context, a *work.Action)
13741370
r.c.saveOutput(a)
13751371
} else {
13761372
base.SetExitStatus(1)
1377-
if len(out) == 0 {
1373+
if cancelSignaled {
1374+
fmt.Fprintf(cmd.Stdout, "*** Test killed with %v: ran too long (%v).\n", base.SignalTrace, testKillTimeout)
1375+
} else if cancelKilled {
1376+
fmt.Fprintf(cmd.Stdout, "*** Test killed: ran too long (%v).\n", testKillTimeout)
1377+
}
1378+
var ee *exec.ExitError
1379+
if len(out) == 0 || !errors.As(err, &ee) || !ee.Exited() {
13781380
// If there was no test output, print the exit status so that the reason
13791381
// for failure is clear.
13801382
fmt.Fprintf(cmd.Stdout, "%s\n", err)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Regression test for https://go.dev/issue/24050:
2+
# a test that exits with an I/O stream held open
3+
# should fail after a reasonable delay, not wait forever.
4+
# (As of the time of writing, that delay is 5 seconds,
5+
# but this test does not depend on its specific value.)
6+
7+
[short] skip 'runs a test that hangs until its WaitDelay expires'
8+
9+
! go test -v -timeout=1m .
10+
11+
# After the test process itself prints PASS and exits,
12+
# the kernel closes its stdin pipe to to the orphaned subprocess.
13+
# At that point, we expect the subprocess to print 'stdin closed'
14+
# and periodically log to stderr until the WaitDelay expires.
15+
#
16+
# Once the WaitDelay expires, the copying goroutine for 'go test' stops and
17+
# closes the read side of the stderr pipe, and the subprocess will eventually
18+
# exit due to a failed write to that pipe.
19+
20+
stdout '^--- PASS: TestOrphanCmd .*\nPASS\nstdin closed'
21+
stdout '^exec: WaitDelay expired before I/O complete\nFAIL\s+example\s+\d+(\.\d+)?s'
22+
23+
-- go.mod --
24+
module example
25+
26+
go 1.20
27+
-- main_test.go --
28+
package main
29+
30+
import (
31+
"fmt"
32+
"io"
33+
"os"
34+
"os/exec"
35+
"testing"
36+
"time"
37+
)
38+
39+
func TestMain(m *testing.M) {
40+
if os.Getenv("TEST_TIMEOUT_HANG") == "1" {
41+
io.Copy(io.Discard, os.Stdin)
42+
if _, err := os.Stderr.WriteString("stdin closed\n"); err != nil {
43+
os.Exit(1)
44+
}
45+
46+
ticker := time.NewTicker(100 * time.Millisecond)
47+
for t := range ticker.C {
48+
_, err := fmt.Fprintf(os.Stderr, "still alive at %v\n", t)
49+
if err != nil {
50+
os.Exit(1)
51+
}
52+
}
53+
}
54+
55+
m.Run()
56+
}
57+
58+
func TestOrphanCmd(t *testing.T) {
59+
exe, err := os.Executable()
60+
if err != nil {
61+
t.Fatal(err)
62+
}
63+
64+
cmd := exec.Command(exe)
65+
cmd.Env = append(cmd.Environ(), "TEST_TIMEOUT_HANG=1")
66+
67+
// Hold stdin open until this (parent) process exits.
68+
if _, err := cmd.StdinPipe(); err != nil {
69+
t.Fatal(err)
70+
}
71+
72+
// Forward stderr to the subprocess so that it can hold the stream open.
73+
cmd.Stderr = os.Stderr
74+
75+
if err := cmd.Start(); err != nil {
76+
t.Fatal(err)
77+
}
78+
t.Logf("started %v", cmd)
79+
80+
// Intentionally leak cmd when the test completes.
81+
// This will allow the test process itself to exit, but (at least on Unix
82+
// platforms) will keep the parent process's stderr stream open.
83+
go func() {
84+
if err := cmd.Wait(); err != nil {
85+
os.Exit(3)
86+
}
87+
}()
88+
}

0 commit comments

Comments
 (0)