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 TestWaitFlags_NoWCONTINUED(t *testing.T) { defer func(saved bool) { jobControl = saved }(jobControl) jobControl = true j := &job{pgid: 1} for _, nohang := range []bool{false, true} { flags := waitFlags(j, nohang) if flags&syscall.WCONTINUED != 0 { t.Errorf("waitFlags(nohang=%v) contains WCONTINUED, which Go decodes wrongly on BSD", nohang) } if flags&syscall.WUNTRACED == 0 { t.Errorf("waitFlags(nohang=%v) misses WUNTRACED", nohang) } } } 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 &") // After bg the job keeps running and is not reported as finished // (regression: on FreeBSD the "continued" status was read as killed by // SIGCONT, "Exit 147") time.Sleep(300 * time.Millisecond) s.send("jobs\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") } }