add job control
- 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>
This commit is contained in:
1 parent
4b44cc6e72
commit
dae4087a94
6 files changed
+990
-73
No files matched your search
@@ -0,0 +1,182 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user