- in an interactive shell every pipeline runs in its own process group and gets the terminal while it runs in the foreground - Ctrl+Z stops the foreground job, command & starts a job in the background; finished and stopped jobs are reported before the prompt - new builtins jobs, fg, bg and wait (wait also works without job control, e.g. in scripts, where background jobs read from /dev/null) - terminal modes are saved and restored when a job stops or continues - exit and Ctrl+D warn once if there are stopped jobs - processes are waited for with wait4 (WUNTRACED) instead of exec.Cmd.Wait - pty based test for job control (Linux) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
4.3 KiB
Go
183 lines
4.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
// openPty opens a new pseudo terminal and returns the master and slave side.
|
|
func openPty(t *testing.T) (*os.File, *os.File) {
|
|
t.Helper()
|
|
master, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0)
|
|
if err != nil {
|
|
t.Skipf("no pty available: %v", err)
|
|
}
|
|
if err := unix.IoctlSetPointerInt(int(master.Fd()), unix.TIOCSPTLCK, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n, err := unix.IoctlGetInt(int(master.Fd()), unix.TIOCGPTN)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
slave, err := os.OpenFile("/dev/pts/"+strconv.Itoa(n), os.O_RDWR|syscall.O_NOCTTY, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return master, slave
|
|
}
|
|
|
|
// ptyShell is the shell running on a pseudo terminal.
|
|
type ptyShell struct {
|
|
t *testing.T
|
|
master *os.File
|
|
mu sync.Mutex
|
|
out bytes.Buffer
|
|
cmd *exec.Cmd
|
|
}
|
|
|
|
func startPtyShell(t *testing.T) *ptyShell {
|
|
bin := buildShell(t)
|
|
master, slave := openPty(t)
|
|
cmd := exec.Command(bin, "--norc")
|
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = slave, slave, slave
|
|
cmd.Env = append(os.Environ(), "NO_COLOR=1")
|
|
// New session with the pty as controlling terminal, like a terminal
|
|
// emulator does
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
slave.Close()
|
|
|
|
s := &ptyShell{t: t, master: master, cmd: cmd}
|
|
go func() {
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := master.Read(buf)
|
|
s.mu.Lock()
|
|
s.out.Write(buf[:n])
|
|
s.mu.Unlock()
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
cmd.Process.Kill()
|
|
cmd.Wait()
|
|
master.Close()
|
|
})
|
|
s.expect("$ ")
|
|
return s
|
|
}
|
|
|
|
func (s *ptyShell) send(text string) {
|
|
s.master.WriteString(text)
|
|
}
|
|
|
|
// expect waits until want appears in the output after the position of the
|
|
// last match and consumes the output up to it.
|
|
func (s *ptyShell) expect(want string) {
|
|
s.t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
s.mu.Lock()
|
|
out := s.out.String()
|
|
if i := strings.Index(out, want); i >= 0 {
|
|
s.out.Next(i + len(want))
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.mu.Unlock()
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.t.Fatalf("timeout waiting for %q, output:\n%s", want, s.out.String())
|
|
}
|
|
|
|
func TestJobControl(t *testing.T) {
|
|
s := startPtyShell(t)
|
|
|
|
// Ctrl+Z stops the foreground job, the shell gets the terminal back
|
|
s.send("sleep 30\n")
|
|
time.Sleep(300 * time.Millisecond)
|
|
s.send("\x1a")
|
|
s.expect("[1]+ Stopped sleep 30")
|
|
s.send("jobs\n")
|
|
s.expect("[1]+ Stopped sleep 30")
|
|
|
|
// bg continues it in the background
|
|
s.send("bg\n")
|
|
s.expect("[1]+ Running sleep 30 &")
|
|
|
|
// fg brings it back, Ctrl+C reaches only the job
|
|
s.send("fg %1\n")
|
|
s.expect("sleep 30")
|
|
time.Sleep(300 * time.Millisecond)
|
|
s.send("\x03")
|
|
s.send("echo status=$?\n")
|
|
s.expect("status=130")
|
|
|
|
// A pipeline is stopped and continued as a whole
|
|
s.send("sleep 30 | cat\n")
|
|
time.Sleep(300 * time.Millisecond)
|
|
s.send("\x1a")
|
|
s.expect("Stopped sleep 30 | cat")
|
|
s.send("fg\n")
|
|
time.Sleep(300 * time.Millisecond)
|
|
s.send("\x03")
|
|
s.send("echo pipeline=$?\n")
|
|
s.expect("pipeline=130")
|
|
|
|
// A background job that reads from the terminal is stopped (SIGTTIN),
|
|
// in the foreground it gets the input
|
|
s.send("cat &\n")
|
|
s.expect("[1] ")
|
|
time.Sleep(300 * time.Millisecond)
|
|
s.send("echo next\n")
|
|
s.expect("[1]+ Stopped cat")
|
|
s.send("fg\n")
|
|
time.Sleep(300 * time.Millisecond)
|
|
s.send("hello cat\n")
|
|
s.expect("hello cat\r\nhello cat")
|
|
s.send("\x04")
|
|
s.send("echo cat=$?\n")
|
|
s.expect("cat=0")
|
|
|
|
// Finished background jobs are reported before the next prompt
|
|
s.send("sleep 0.2 &\n")
|
|
time.Sleep(500 * time.Millisecond)
|
|
s.send("echo tick\n")
|
|
s.expect("Done sleep 0.2")
|
|
|
|
// exit warns about stopped jobs once
|
|
s.send("sleep 30\n")
|
|
time.Sleep(300 * time.Millisecond)
|
|
s.send("\x1a")
|
|
s.expect("Stopped")
|
|
s.send("exit\n")
|
|
s.expect("there are stopped jobs")
|
|
s.send("exit 0\n")
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- s.cmd.Wait() }()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Errorf("shell exited with %v", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("shell did not exit")
|
|
}
|
|
}
|