mirror of
https://github.com/golang/go
synced 2024-11-18 00:14:47 -07:00
os/signal: rewrite TestTerminalSignal without bash
The existing version of this test contains several races it tries to control with sleeps. Unfortunately, it is still flaky on darwin because writing `fg` in bash too early can apparently result in failure to actually continue the stopped child. Rather than continuing to get perfect timing with bash, rewrite this to eliminate bash and instead perform the same PTY operations that bash would do. This test is still quite complex because psuedo-terminals are interminably complicated, but I believe it is no longer racy. Technically there are still two races (waiting for child to enter read() and waiting for the darwin kernel to wake the read after TIOCSPGRP), but loss of either of these races should only mean we fail to test the desired darwin EINTR case, not failure. This test is skipped on DragonflyBSD, as it tickles a Wait hang bug (#56132). Updates #56132. Fixes #37329. Change-Id: I0ceaf5aa89f6be0f1bf68b2140f47db673cedb33 Reviewed-on: https://go-review.googlesource.com/c/go/+/440220 Run-TryBot: Michael Pratt <mpratt@google.com> Auto-Submit: Michael Pratt <mpratt@google.com> TryBot-Result: Gopher Robot <gobot@golang.org> Reviewed-by: Bryan Mills <bcmills@google.com>
This commit is contained in:
parent
cba63ac038
commit
a8ca70ff98
@ -11,52 +11,72 @@
|
||||
package signal_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
ptypkg "os/signal/internal/pty"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ptyFD = 3 // child end of pty.
|
||||
controlFD = 4 // child end of control pipe.
|
||||
)
|
||||
|
||||
// TestTerminalSignal tests that read from a pseudo-terminal does not return an
|
||||
// error if the process is SIGSTOP'd and put in the background during the read.
|
||||
//
|
||||
// This test simulates stopping a Go process running in a shell with ^Z and
|
||||
// then resuming with `fg`.
|
||||
//
|
||||
// This is a regression test for https://go.dev/issue/22838. On Darwin, PTY
|
||||
// reads return EINTR when this occurs, and Go should automatically retry.
|
||||
func TestTerminalSignal(t *testing.T) {
|
||||
const enteringRead = "test program entering read"
|
||||
if os.Getenv("GO_TEST_TERMINAL_SIGNALS") != "" {
|
||||
var b [1]byte
|
||||
fmt.Println(enteringRead)
|
||||
n, err := os.Stdin.Read(b[:])
|
||||
if n == 1 {
|
||||
if b[0] == '\n' {
|
||||
// This is what we expect
|
||||
fmt.Println("read newline")
|
||||
} else {
|
||||
fmt.Printf("read 1 byte: %q\n", b)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("read %d bytes\n", n)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
// This test simulates stopping a Go process running in a shell with ^Z
|
||||
// and then resuming with `fg`. This sounds simple, but is actually
|
||||
// quite complicated.
|
||||
//
|
||||
// In principle, what we are doing is:
|
||||
// 1. Creating a new PTY parent/child FD pair.
|
||||
// 2. Create a child that is in the foreground process group of the PTY, and read() from that process.
|
||||
// 3. Stop the child with ^Z.
|
||||
// 4. Take over as foreground process group of the PTY from the parent.
|
||||
// 5. Make the child foreground process group again.
|
||||
// 6. Continue the child.
|
||||
//
|
||||
// On Darwin, step 4 results in the read() returning EINTR once the
|
||||
// process continues. internal/poll should automatically retry the
|
||||
// read.
|
||||
//
|
||||
// These steps are complicated by the rules around foreground process
|
||||
// groups. A process group cannot be foreground if it is "orphaned",
|
||||
// unless it masks SIGTTOU. i.e., to be foreground the process group
|
||||
// must have a parent process group in the same session or mask SIGTTOU
|
||||
// (which we do). An orphaned process group cannot receive
|
||||
// terminal-generated SIGTSTP at all.
|
||||
//
|
||||
// Achieving this requires three processes total:
|
||||
// - Top-level process: this is the main test process and creates the
|
||||
// pseudo-terminal.
|
||||
// - GO_TEST_TERMINAL_SIGNALS=1: This process creates a new process
|
||||
// group and session. The PTY is the controlling terminal for this
|
||||
// session. This process masks SIGTTOU, making it eligible to be a
|
||||
// foreground process group. This process will take over as foreground
|
||||
// from subprocess 2 (step 4 above).
|
||||
// - GO_TEST_TERMINAL_SIGNALS=2: This process create a child process
|
||||
// group of subprocess 1, and is the original foreground process group
|
||||
// for the PTY. This subprocess is the one that is SIGSTOP'd.
|
||||
|
||||
t.Parallel()
|
||||
|
||||
// The test requires a shell that uses job control.
|
||||
bash, err := exec.LookPath("bash")
|
||||
if err != nil {
|
||||
t.Skipf("could not find bash: %v", err)
|
||||
if runtime.GOOS == "dragonfly" {
|
||||
t.Skip("skipping: wait hangs on dragonfly; see https://go.dev/issue/56132")
|
||||
}
|
||||
|
||||
scale := 1
|
||||
@ -66,10 +86,24 @@ func TestTerminalSignal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
pause := time.Duration(scale) * 10 * time.Millisecond
|
||||
wait := time.Duration(scale) * 5 * time.Second
|
||||
|
||||
// The test only fails when using a "slow device," in this
|
||||
// case a pseudo-terminal.
|
||||
lvl := os.Getenv("GO_TEST_TERMINAL_SIGNALS")
|
||||
switch lvl {
|
||||
case "":
|
||||
// Main test process, run code below.
|
||||
break
|
||||
case "1":
|
||||
runSessionLeader(pause)
|
||||
panic("unreachable")
|
||||
case "2":
|
||||
runStoppingChild()
|
||||
panic("unreachable")
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown subprocess level %s", lvl)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
t.Parallel()
|
||||
|
||||
pty, procTTYName, err := ptypkg.Open()
|
||||
if err != nil {
|
||||
@ -86,19 +120,26 @@ func TestTerminalSignal(t *testing.T) {
|
||||
}
|
||||
defer procTTY.Close()
|
||||
|
||||
// Start an interactive shell.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
// Control pipe. GO_TEST_TERMINAL_SIGNALS=2 send the PID of
|
||||
// GO_TEST_TERMINAL_SIGNALS=3 here. After SIGSTOP, it also writes a
|
||||
// byte to indicate that the foreground cycling is complete.
|
||||
controlR, controlW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, bash, "--norc", "--noprofile", "--noediting", "-i")
|
||||
// Clear HISTFILE so that we don't read or clobber the user's bash history.
|
||||
cmd.Env = append(os.Environ(), "HISTFILE=")
|
||||
cmd.Stdin = procTTY
|
||||
cmd.Stdout = procTTY
|
||||
cmd.Stderr = procTTY
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestTerminalSignal")
|
||||
cmd.Env = append(os.Environ(), "GO_TEST_TERMINAL_SIGNALS=1")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout // for logging
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.ExtraFiles = []*os.File{procTTY, controlW}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
Setctty: true,
|
||||
Ctty: 0,
|
||||
Ctty: ptyFD,
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
@ -109,78 +150,31 @@ func TestTerminalSignal(t *testing.T) {
|
||||
t.Errorf("closing procTTY: %v", err)
|
||||
}
|
||||
|
||||
progReady := make(chan bool)
|
||||
sawPrompt := make(chan bool, 10)
|
||||
const prompt = "prompt> "
|
||||
|
||||
// Read data from pty in the background.
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
defer wg.Wait()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
input := bufio.NewReader(pty)
|
||||
var line, handled []byte
|
||||
for {
|
||||
b, err := input.ReadByte()
|
||||
if err != nil {
|
||||
if len(line) > 0 || len(handled) > 0 {
|
||||
t.Logf("%q", append(handled, line...))
|
||||
}
|
||||
if perr, ok := err.(*fs.PathError); ok {
|
||||
err = perr.Err
|
||||
}
|
||||
// EOF means pty is closed.
|
||||
// EIO means child process is done.
|
||||
// "file already closed" means deferred close of pty has happened.
|
||||
if err != io.EOF && err != syscall.EIO && !strings.Contains(err.Error(), "file already closed") {
|
||||
t.Logf("error reading from pty: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
line = append(line, b)
|
||||
|
||||
if b == '\n' {
|
||||
t.Logf("%q", append(handled, line...))
|
||||
line = nil
|
||||
handled = nil
|
||||
continue
|
||||
}
|
||||
|
||||
if bytes.Contains(line, []byte(enteringRead)) {
|
||||
close(progReady)
|
||||
handled = append(handled, line...)
|
||||
line = nil
|
||||
} else if bytes.Contains(line, []byte(prompt)) && !bytes.Contains(line, []byte("PS1=")) {
|
||||
sawPrompt <- true
|
||||
handled = append(handled, line...)
|
||||
line = nil
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Set the bash prompt so that we can see it.
|
||||
if _, err := pty.Write([]byte("PS1='" + prompt + "'\n")); err != nil {
|
||||
t.Fatalf("setting prompt: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-sawPrompt:
|
||||
case <-time.After(wait):
|
||||
t.Fatal("timed out waiting for shell prompt")
|
||||
if err := controlW.Close(); err != nil {
|
||||
t.Errorf("closing controlW: %v", err)
|
||||
}
|
||||
|
||||
// Start a small program that reads from stdin
|
||||
// (namely the code at the top of this function).
|
||||
if _, err := pty.Write([]byte("GO_TEST_TERMINAL_SIGNALS=1 " + os.Args[0] + " -test.run=TestTerminalSignal\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
// Wait for first child to send the second child's PID.
|
||||
b := make([]byte, 8)
|
||||
n, err := controlR.Read(b)
|
||||
if err != nil {
|
||||
t.Fatalf("error reading child pid: %v\n", err)
|
||||
}
|
||||
if n != 8 {
|
||||
t.Fatalf("unexpected short read n = %d\n", n)
|
||||
}
|
||||
pid := binary.LittleEndian.Uint64(b[:])
|
||||
process, err := os.FindProcess(int(pid))
|
||||
if err != nil {
|
||||
t.Fatalf("unable to find child process: %v", err)
|
||||
}
|
||||
|
||||
// Wait for the program to print that it is starting.
|
||||
select {
|
||||
case <-progReady:
|
||||
case <-time.After(wait):
|
||||
t.Fatal("timed out waiting for program to start")
|
||||
// Wait for the third child to write a byte indicating that it is
|
||||
// entering the read.
|
||||
b = make([]byte, 1)
|
||||
_, err = pty.Read(b)
|
||||
if err != nil {
|
||||
t.Fatalf("error reading from child: %v", err)
|
||||
}
|
||||
|
||||
// Give the program time to enter the read call.
|
||||
@ -189,51 +183,168 @@ func TestTerminalSignal(t *testing.T) {
|
||||
// will pass.
|
||||
time.Sleep(pause)
|
||||
|
||||
t.Logf("Sending ^Z...")
|
||||
|
||||
// Send a ^Z to stop the program.
|
||||
if _, err := pty.Write([]byte{26}); err != nil {
|
||||
t.Fatalf("writing ^Z to pty: %v", err)
|
||||
}
|
||||
|
||||
// Wait for the program to stop and return to the shell.
|
||||
select {
|
||||
case <-sawPrompt:
|
||||
case <-time.After(wait):
|
||||
t.Fatal("timed out waiting for shell prompt")
|
||||
// Wait for subprocess 1 to cycle the foreground process group.
|
||||
if _, err := controlR.Read(b); err != nil {
|
||||
t.Fatalf("error reading readiness: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Sending SIGCONT...")
|
||||
|
||||
// Restart the stopped program.
|
||||
if _, err := pty.Write([]byte("fg\n")); err != nil {
|
||||
t.Fatalf("writing %q to pty: %v", "fg", err)
|
||||
if err := process.Signal(syscall.SIGCONT); err != nil {
|
||||
t.Fatalf("Signal(SIGCONT) got err %v want nil", err)
|
||||
}
|
||||
|
||||
// Give the process time to restart.
|
||||
// This is potentially racy: if the process does not restart
|
||||
// quickly enough then the byte we send will go to bash rather
|
||||
// than the program. Unfortunately there isn't anything we can
|
||||
// look for to know that the program is running again.
|
||||
// bash will print the program name, but that happens before it
|
||||
// restarts the program.
|
||||
time.Sleep(10 * pause)
|
||||
|
||||
// Write some data for the program to read,
|
||||
// which should cause it to exit.
|
||||
// Write some data for the program to read, which should cause it to
|
||||
// exit.
|
||||
if _, err := pty.Write([]byte{'\n'}); err != nil {
|
||||
t.Fatalf("writing %q to pty: %v", "\n", err)
|
||||
}
|
||||
|
||||
// Wait for the program to exit.
|
||||
select {
|
||||
case <-sawPrompt:
|
||||
case <-time.After(wait):
|
||||
t.Fatal("timed out waiting for shell prompt")
|
||||
}
|
||||
|
||||
// Exit the shell with the program's exit status.
|
||||
if _, err := pty.Write([]byte("exit $?\n")); err != nil {
|
||||
t.Fatalf("writing %q to pty: %v", "exit", err)
|
||||
}
|
||||
t.Logf("Waiting for exit...")
|
||||
|
||||
if err = cmd.Wait(); err != nil {
|
||||
t.Errorf("subprogram failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GO_TEST_TERMINAL_SIGNALS=1 subprocess above.
|
||||
func runSessionLeader(pause time.Duration) {
|
||||
// "Attempts to use tcsetpgrp() from a process which is a
|
||||
// member of a background process group on a fildes associated
|
||||
// with its controlling terminal shall cause the process group
|
||||
// to be sent a SIGTTOU signal. If the calling thread is
|
||||
// blocking SIGTTOU signals or the process is ignoring SIGTTOU
|
||||
// signals, the process shall be allowed to perform the
|
||||
// operation, and no signal is sent."
|
||||
// -https://pubs.opengroup.org/onlinepubs/9699919799/functions/tcsetpgrp.html
|
||||
//
|
||||
// We are changing the terminal to put us in the foreground, so
|
||||
// we must ignore SIGTTOU. We are also an orphaned process
|
||||
// group (see above), so we must mask SIGTTOU to be eligible to
|
||||
// become foreground at all.
|
||||
signal.Ignore(syscall.SIGTTOU)
|
||||
|
||||
pty := os.NewFile(ptyFD, "pty")
|
||||
controlW := os.NewFile(controlFD, "control-pipe")
|
||||
|
||||
// Slightly shorter timeout than in the parent.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestTerminalSignal")
|
||||
cmd.Env = append(os.Environ(), "GO_TEST_TERMINAL_SIGNALS=2")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.ExtraFiles = []*os.File{pty}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Foreground: true,
|
||||
Ctty: ptyFD,
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error starting second subprocess: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fn := func() error {
|
||||
var b [8]byte
|
||||
binary.LittleEndian.PutUint64(b[:], uint64(cmd.Process.Pid))
|
||||
_, err := controlW.Write(b[:])
|
||||
if err != nil {
|
||||
return fmt.Errorf("error writing child pid: %w", err)
|
||||
}
|
||||
|
||||
// Wait for stop.
|
||||
var status syscall.WaitStatus
|
||||
var errno syscall.Errno
|
||||
for {
|
||||
_, _, errno = syscall.Syscall6(syscall.SYS_WAIT4, uintptr(cmd.Process.Pid), uintptr(unsafe.Pointer(&status)), syscall.WUNTRACED, 0, 0, 0)
|
||||
if errno != syscall.EINTR {
|
||||
break
|
||||
}
|
||||
}
|
||||
if errno != 0 {
|
||||
return fmt.Errorf("error waiting for stop: %w", errno)
|
||||
}
|
||||
|
||||
if !status.Stopped() {
|
||||
return fmt.Errorf("unexpected wait status: %v", status)
|
||||
}
|
||||
|
||||
// Take TTY.
|
||||
pgrp := syscall.Getpgrp()
|
||||
_, _, errno = syscall.Syscall(syscall.SYS_IOCTL, ptyFD, syscall.TIOCSPGRP, uintptr(unsafe.Pointer(&pgrp)))
|
||||
if errno != 0 {
|
||||
return fmt.Errorf("error setting tty process group: %w", errno)
|
||||
}
|
||||
|
||||
// Give the kernel time to potentially wake readers and have
|
||||
// them return EINTR (darwin does this).
|
||||
time.Sleep(pause)
|
||||
|
||||
// Give TTY back.
|
||||
pid := uint64(cmd.Process.Pid)
|
||||
_, _, errno = syscall.Syscall(syscall.SYS_IOCTL, ptyFD, syscall.TIOCSPGRP, uintptr(unsafe.Pointer(&pid)))
|
||||
if errno != 0 {
|
||||
return fmt.Errorf("error setting tty process group back: %w", errno)
|
||||
}
|
||||
|
||||
// Report that we are done and SIGCONT can be sent. Note that
|
||||
// the actual byte we send doesn't matter.
|
||||
if _, err := controlW.Write(b[:1]); err != nil {
|
||||
return fmt.Errorf("error writing readiness: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
err := fn()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "session leader error: %v", err)
|
||||
cmd.Process.Kill()
|
||||
// Wait for exit below.
|
||||
}
|
||||
|
||||
werr := cmd.Wait()
|
||||
if werr != nil {
|
||||
fmt.Fprintf(os.Stderr, "error running second subprocess: %v\n", err)
|
||||
}
|
||||
|
||||
if err != nil || werr != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// GO_TEST_TERMINAL_SIGNALS=2 subprocess above.
|
||||
func runStoppingChild() {
|
||||
pty := os.NewFile(ptyFD, "pty")
|
||||
|
||||
var b [1]byte
|
||||
if _, err := pty.Write(b[:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error writing byte to PTY: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
_, err := pty.Read(b[:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if b[0] == '\n' {
|
||||
// This is what we expect
|
||||
fmt.Println("read newline")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "read 1 unexpected byte: %q\n", b)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user