diff --git a/README.md b/README.md index 25559ba..4dc1bd2 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ Built-in commands: set VAR=val - Set environment variable export VAR=val - Export environment variable source file - Run the commands of file in this shell (also: . file) + jobs - List background and stopped jobs + fg [%n] - Continue job n (default: current job) in the foreground + bg [%n] - Continue stopped job n in the background + wait [%n] - Wait for job n or all background jobs help - Show this help Features: @@ -37,6 +41,8 @@ Features: a || b (b only if a failed); $? is the exit status of the last command - Pipes (a | b) and redirections: < file, > file, >> file, 2> file, 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file + - Job control: command & runs in the background, Ctrl+Z stops the + foreground job; jobs, fg, bg and wait manage the jobs - Non-interactive use: gosh -c 'command', gosh script, command | gosh; the exit status is the one of the last command - Startup files: login shells (-l or argv[0] starting with -) read @@ -179,7 +185,42 @@ $ grep -c error < build.log - Operatoren in Anführungszeichen oder mit `\` davor sind normale Zeichen: `echo "a|b" 'c>d' e\>f`. - Der Exit-Status einer Pipeline ist der des letzten Befehls. Beendet sich ein Befehl am Ende (z. B. `head`), bekommt der schreibende Befehl davor `SIGPIPE`, `yes | head -1` endet also sofort. - Die Built-ins `pwd` und `help` lassen sich umleiten und in Pipes verwenden (`help | less`). `cd`, `set`, `export`, `exit` und `quit` verändern die Shell selbst und sind in einer Pipeline nicht erlaubt. -- Ein einzelnes `&` (Hintergrundprozesse) gibt es noch nicht, es wird als normales Zeichen übergeben. + +### Job-Control + +In einer interaktiven Shell läuft jede Befehlszeile (bzw. jede Pipeline) als eigener *Job* in einer eigenen Prozessgruppe: + +``` +$ sleep 100 & # im Hintergrund starten +[1] 12345 +$ vim notizen.txt # Ctrl+Z hält vim an +^Z +[2]+ Stopped vim notizen.txt +$ jobs +[1]- Running sleep 100 & +[2]+ Stopped vim notizen.txt +$ fg # vim wieder im Vordergrund +$ bg %2 # oder: angehaltenen Job im Hintergrund weiterlaufen lassen +``` + +| Befehl / Taste | Bedeutung | +|----------------|---------------------------------------------------------------------------| +| `befehl &` | startet den Befehl im Hintergrund, gosh wartet nicht auf ihn (`$?` ist 0) | +| Ctrl+Z | hält den Vordergrund-Job an (`$?` wird 128+SIGTSTP, unter Linux 148) | +| Ctrl+C | bricht nur den Vordergrund-Job ab, nicht die Shell | +| `jobs` | listet laufende und angehaltene Jobs | +| `fg [%n]` | holt Job `n` in den Vordergrund (und setzt ihn fort, falls angehalten) | +| `bg [%n]` | setzt einen angehaltenen Job im Hintergrund fort | +| `wait [%n]` | wartet auf Job `n` bzw. auf alle Hintergrund-Jobs | + +- Ohne Angabe gilt der *aktuelle* Job (in `jobs` mit `+` markiert, der zuletzt angehaltene oder in den Hintergrund geschickte), `%-` ist der vorherige (`-`). `%n` und `n` meinen Job Nummer `n`. +- Beendete Hintergrund-Jobs meldet gosh vor dem nächsten Prompt (`[1]+ Done sleep 100`, bei Fehlern `Exit N`). +- Liest ein Hintergrund-Job vom Terminal, wird er angehalten (wie in bash), mit `fg` bekommt er die Eingabe. +- Die Terminal-Einstellungen werden gesichert und wiederhergestellt: Nach dem Anhalten von `vim` oder `less` funktioniert der Prompt normal, nach `fg` bekommt das Programm seine Einstellungen zurück. +- `exit` (oder Ctrl+D) mit angehaltenen Jobs warnt einmal (`there are stopped jobs`), ein zweites `exit` direkt danach beendet gosh. +- `cd`, `set`, `export`, `exit`, `source`, `fg`, `bg` und `wait` lassen sich nicht mit `&` im Hintergrund starten. +- In Skripten und mit `gosh -c` gibt es keine Job-Control (kein Ctrl+Z, kein `fg`/`bg`), `&` und `wait` funktionieren aber. Hintergrund-Jobs lesen dort von `/dev/null`. +- Einen Job beenden lässt sich mit `kill` über die Prozess-ID (z. B. `kill 12345`); `kill %1` versteht gosh noch nicht. ### Farben diff --git a/go.mod b/go.mod index f05efcc..c61156a 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,4 @@ require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 ) -require golang.org/x/sys v0.48.0 // indirect +require golang.org/x/sys v0.48.0 diff --git a/jobs.go b/jobs.go new file mode 100644 index 0000000..7d11ec9 --- /dev/null +++ b/jobs.go @@ -0,0 +1,525 @@ +package main + +// Job control: in an interactive shell every pipeline runs in its own process +// group, which gets the terminal while it runs in the foreground. Ctrl+Z stops +// the foreground job, "&" starts a job in the background, and the builtins +// jobs, fg, bg and wait manage the jobs. Without a terminal (scripts, gosh -c) +// there is no job control, but "&" and wait still work. + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "strconv" + "strings" + "syscall" + + "github.com/chzyer/readline" + "golang.org/x/sys/unix" +) + +// proc is a stage of a job: a process, or a builtin or a stage that failed to +// start (both without process). +type proc struct { + cmd *exec.Cmd // nil if the stage is no process + done chan error // the result of a stage without process + finished bool + stopped bool + err error +} + +// job is a pipeline that was started by the shell. +type job struct { + id int // number in the job table, 0 if not in the table + pgid int // process group, 0 without job control or processes + text string // the command line + procs []*proc + tty *readline.State // terminal modes of the job when it was stopped +} + +var ( + jobControl bool // job control is enabled + interactiveShell bool // the shell reads commands from a user + ttyFd int // the terminal of the shell + shellPgid int // the process group of the shell + origPgid int // the foreground process group at start + shellTTY *readline.State // terminal modes of the shell + jobTable []*job // background and stopped jobs + exitWarned bool // "there are stopped jobs" was shown +) + +// initJobControl enables job control if stdin is a terminal: the shell moves +// into its own process group and takes the terminal. +func initJobControl() { + fd := int(os.Stdin.Fd()) + if !readline.IsTerminal(fd) { + return + } + pgrp, err := unix.IoctlGetInt(fd, unix.TIOCGPGRP) + if err != nil { + return + } + pid := os.Getpid() + if syscall.Getpgrp() != pid { + if err := syscall.Setpgid(0, 0); err != nil { + return + } + } + origPgid, ttyFd, shellPgid = pgrp, fd, pid + if err := setForeground(pid); err != nil { + return + } + // Ctrl+Z must not stop the shell itself. (signal.Ignore would be + // inherited by the child processes.) + signal.Notify(make(chan os.Signal, 1), syscall.SIGTSTP) + jobControl = true +} + +// releaseTerminal gives the terminal back to the process group that had it +// when the shell started. +func releaseTerminal() { + if jobControl && origPgid > 0 && origPgid != shellPgid { + setForeground(origPgid) + } +} + +// setForeground makes pgid the foreground process group of the terminal. +func setForeground(pgid int) error { + // The shell gets SIGTTOU if it does this while it is in the background + // (when it takes the terminal back from a job), unless the signal is + // ignored. It is only ignored for this call, since ignored signals are + // inherited by child processes. + signal.Ignore(syscall.SIGTTOU) + defer signal.Reset(syscall.SIGTTOU) + return unix.IoctlSetPointerInt(ttyFd, unix.TIOCSPGRP, pgid) +} + +// ttyIndex returns the index of the file in fds that is the terminal of the +// shell, or -1. +func ttyIndex(fds [3]*os.File) int { + tty, err := os.Stdin.Stat() + if err != nil { + return -1 + } + for i, f := range fds { + if info, err := f.Stat(); err == nil && os.SameFile(info, tty) { + return i + } + } + return -1 +} + +// finishedProc returns a proc without process that already has its result. +func finishedProc(err error) *proc { + p := &proc{done: make(chan error, 1)} + p.done <- err + return p +} + +// running reports whether a process of the job is running. +func (j *job) running() bool { + for _, p := range j.procs { + if p.cmd != nil && !p.finished && !p.stopped { + return true + } + } + return false +} + +// stopped reports whether the job is stopped: no process is running, but +// one is stopped. +func (j *job) stopped() bool { + if j.running() { + return false + } + for _, p := range j.procs { + if p.stopped { + return true + } + } + return false +} + +// finished reports whether all stages of the job have finished. +func (j *job) finished() bool { + for _, p := range j.procs { + if !p.finished { + return false + } + } + return true +} + +// update records a status change of process pid. +func (j *job) update(pid int, ws syscall.WaitStatus) { + for _, p := range j.procs { + if p.cmd == nil || p.cmd.Process.Pid != pid { + continue + } + switch { + case ws.Stopped(): + p.stopped = true + case ws.Continued(): + p.stopped = false + default: + p.finished, p.stopped = true, false + p.err = waitStatusError(ws) + p.cmd.Process.Release() + } + } +} + +// waitStatusError converts the status of a finished process into an error: +// nil for exit code 0, otherwise a statusError with the exit code, or 128+n +// if the process was killed by signal n. +func waitStatusError(ws syscall.WaitStatus) error { + switch { + case ws.Signaled(): + return statusError(128 + int(ws.Signal())) + case ws.ExitStatus() != 0: + return statusError(ws.ExitStatus()) + } + return nil +} + +// wait waits until no process of the job is running any more (all finished +// or, with job control, stopped). With nohang it only collects the status +// changes that are already available and does not block. +func (j *job) wait(nohang bool) { + flags := 0 + if jobControl && j.pgid != 0 { + flags |= syscall.WUNTRACED | syscall.WCONTINUED + } + if nohang { + flags |= syscall.WNOHANG + } + + for { + // With job control all processes of the job are in its process + // group, otherwise they are waited for one by one. + target := 0 + if jobControl && j.pgid != 0 { + if !j.running() && !(nohang && j.stopped()) { + break + } + target = -j.pgid + } else { + if nohang { + // Check every process once + for _, p := range j.procs { + if p.cmd == nil || p.finished { + continue + } + var ws syscall.WaitStatus + if pid, err := syscall.Wait4(p.cmd.Process.Pid, &ws, flags, nil); err == nil && pid > 0 { + j.update(pid, ws) + } + } + break + } + for _, p := range j.procs { + if p.cmd != nil && !p.finished { + target = p.cmd.Process.Pid + break + } + } + if target == 0 { + break + } + } + + var ws syscall.WaitStatus + pid, err := syscall.Wait4(target, &ws, flags, nil) + if errors.Is(err, syscall.EINTR) { + continue + } + if err != nil { + // Should not happen; do not wait forever + for _, p := range j.procs { + if p.cmd != nil && !p.finished { + p.finished, p.stopped = true, false + } + } + break + } + if pid == 0 { + break // nohang and nothing changed + } + j.update(pid, ws) + } + + // Stages without process (builtins in pipelines) + for _, p := range j.procs { + if p.cmd != nil || p.finished { + continue + } + if nohang || j.stopped() { + select { + case p.err = <-p.done: + p.finished = true + default: + } + } else { + p.err = <-p.done + p.finished = true + } + } +} + +// result returns the error of the last stage of a finished job (so $? is its +// exit status, like in sh) and prints the errors of the other stages. +func (j *job) result() error { + for _, p := range j.procs[:len(j.procs)-1] { + printError(p.err) + } + return j.procs[len(j.procs)-1].err +} + +// waitForeground waits for a job in the foreground. If it is stopped, it is +// added to the job table. +func waitForeground(j *job) error { + j.wait(false) + if jobControl && j.pgid != 0 { + setForeground(shellPgid) + if j.stopped() { + j.tty, _ = readline.GetState(ttyFd) + } + if shellTTY != nil { + readline.Restore(ttyFd, shellTTY) + } + } + + if j.stopped() { + addJob(j) + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, j.describe("Stopped")) + return statusError(128 + int(syscall.SIGTSTP)) + } + removeJob(j) + j.wait(false) // wait for the builtins, if any + return j.result() +} + +// addJob adds j to the job table (if it is not there yet) and makes it the +// current job. +func addJob(j *job) { + removeJob(j) + if j.id == 0 { + for _, other := range jobTable { + j.id = max(j.id, other.id) + } + j.id++ + } + jobTable = append(jobTable, j) +} + +// removeJob removes j from the job table. +func removeJob(j *job) { + for i, other := range jobTable { + if other == j { + jobTable = append(jobTable[:i], jobTable[i+1:]...) + return + } + } +} + +// describe formats a job for jobs and the notifications, like bash: +// "[1]+ Stopped sleep 30". The current job (the last one in +// the table, i.e. the one stopped or put into the background most recently) is +// marked with +, the previous one with -. +func (j *job) describe(state string) string { + mark := ' ' + if n := len(jobTable); n > 0 && jobTable[n-1] == j { + mark = '+' + } else if n > 1 && jobTable[n-2] == j { + mark = '-' + } + text := j.text + if state == "Running" { + text += " &" + } + return fmt.Sprintf("[%d]%c %-24s%s", j.id, mark, state, sanitize(text)) +} + +// state returns the state of a job for jobs and the notifications. +func (j *job) state() string { + switch { + case j.stopped(): + return "Stopped" + case !j.finished(): + return "Running" + } + if status := exitStatus(j.procs[len(j.procs)-1].err); status != 0 { + return "Exit " + strconv.Itoa(status) + } + return "Done" +} + +// hasStoppedJobs reports whether a job in the table is stopped. +func hasStoppedJobs() bool { + for _, j := range jobTable { + if j.stopped() { + return true + } + } + return false +} + +// reapJobs collects the status changes of the background jobs, reports +// finished and newly stopped jobs (if notify is set) and removes finished +// jobs from the table. +func reapJobs(notify bool) { + for _, j := range append([]*job(nil), jobTable...) { + wasStopped := j.stopped() + j.wait(true) + switch { + case j.finished(): + if notify { + for _, p := range j.procs { + printError(p.err) + } + fmt.Fprintln(os.Stderr, j.describe(j.state())) + } + removeJob(j) + case j.stopped() && !wasStopped: + addJob(j) // the current job now + if notify { + fmt.Fprintln(os.Stderr, j.describe("Stopped")) + } + } + } +} + +// findJob returns the job for a job spec: %N or N for job N, %+, %% or nothing +// for the current job, %- for the previous one. +func findJob(cmd string, args []string) (*job, error) { + spec := "%+" + if len(args) > 1 { + spec = args[1] + } + n := len(jobTable) + switch spec { + case "%+", "%%", "%": + if n > 0 { + return jobTable[n-1], nil + } + return nil, fmt.Errorf("%s: current: no such job", cmd) + case "%-": + if n > 1 { + return jobTable[n-2], nil + } + return nil, fmt.Errorf("%s: previous: no such job", cmd) + } + id, err := strconv.Atoi(strings.TrimPrefix(spec, "%")) + if err == nil { + for _, j := range jobTable { + if j.id == id { + return j, nil + } + } + } + return nil, fmt.Errorf("%s: %s: no such job", cmd, sanitize(spec)) +} + +// continueJob sends SIGCONT to a stopped job. +func continueJob(j *job) { + for _, p := range j.procs { + p.stopped = false + } + if j.pgid != 0 { + syscall.Kill(-j.pgid, syscall.SIGCONT) + } +} + +// builtinJobs lists the jobs. +func builtinJobs(stdout io.Writer) error { + for _, j := range jobTable { + j.wait(true) + } + for _, j := range append([]*job(nil), jobTable...) { + fmt.Fprintln(stdout, j.describe(j.state())) + if j.finished() { + removeJob(j) + } + } + return nil +} + +// builtinFg continues a job in the foreground and waits for it. +func builtinFg(args []string, stdout io.Writer) error { + if !jobControl { + return errors.New("fg: no job control") + } + j, err := findJob("fg", args) + if err != nil { + return err + } + fmt.Fprintln(stdout, sanitize(j.text)) + + if j.pgid != 0 { + shellTTY, _ = readline.GetState(ttyFd) + if j.stopped() && j.tty != nil { + readline.Restore(ttyFd, j.tty) + } + setForeground(j.pgid) + } + continueJob(j) + return waitForeground(j) +} + +// builtinBg continues a stopped job in the background. +func builtinBg(args []string, stdout io.Writer) error { + if !jobControl { + return errors.New("bg: no job control") + } + j, err := findJob("bg", args) + if err != nil { + return err + } + if !j.stopped() { + return fmt.Errorf("bg: job %d already in background", j.id) + } + continueJob(j) + addJob(j) // the current job now + fmt.Fprintln(stdout, j.describe("Running")) + return nil +} + +// builtinWait waits for a job, or for all background jobs. Stopped jobs are +// not waited for. +func builtinWait(args []string) error { + jobs := append([]*job(nil), jobTable...) + if len(args) > 1 { + j, err := findJob("wait", args) + if err != nil { + return err + } + jobs = []*job{j} + } + + var err error + for _, j := range jobs { + j.wait(false) + err = nil + if j.finished() { + removeJob(j) + err = j.result() + } + } + return err +} + +// exitShell exits the shell, unless there are stopped jobs and the user was +// not warned yet (like bash). +func exitShell(code int) error { + if interactiveShell && hasStoppedJobs() && !exitWarned { + exitWarned = true + return errors.New("there are stopped jobs") + } + releaseTerminal() + os.Exit(code) + return nil +} diff --git a/jobs_linux_test.go b/jobs_linux_test.go new file mode 100644 index 0000000..01e6b22 --- /dev/null +++ b/jobs_linux_test.go @@ -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") + } +} diff --git a/main.go b/main.go index 6ec590a..581cf67 100644 --- a/main.go +++ b/main.go @@ -702,7 +702,7 @@ type shellCompleter struct { func newShellCompleter() *shellCompleter { return &shellCompleter{ - commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "help"}, + commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "help"}, } } @@ -1109,6 +1109,10 @@ func exitStatus(err error) int { // interactive runs the interactive shell with line editing, history and // completion until the user exits. func interactive() { + interactiveShell = true + initJobControl() + defer releaseTerminal() + // Ctrl+C sends SIGINT to the whole foreground process group. Catch it so // that it only stops the running command and not the shell. (signal.Ignore // would be inherited by the child processes.) @@ -1139,6 +1143,9 @@ func interactive() { // Main loop for { + // Report finished and stopped background jobs + reapJobs(true) + // Update prompt dynamically (in case directory changed) l.SetPrompt(getPrompt()) @@ -1151,6 +1158,11 @@ func interactive() { // Handle EOF (Ctrl+D) if err == io.EOF { fmt.Println() + if hasStoppedJobs() && !exitWarned { + exitWarned = true + fmt.Fprintln(os.Stderr, "gosh: there are stopped jobs") + continue + } break } fmt.Fprintln(os.Stderr, "Error reading input:", err) @@ -1211,11 +1223,12 @@ func printError(err error) { // listItem is a command of a command list together with the operator that // connects it to the previous command ("" for the first one). type listItem struct { - op string // "", ";", "&&" or "||" - command string + op string // "", ";", "&&" or "||" + command string + background bool // the command ends with & } -// splitList splits input at unquoted ;, && and ||. Quotes, escapes, ${...} +// splitList splits input at unquoted ;, &, && and ||. Quotes, escapes, ${...} // and comments are handled like in splitWords, but nothing is expanded yet: // that happens per command when it runs, so "cd /tmp && echo $PWD" works. func splitList(input string) ([]listItem, error) { @@ -1275,6 +1288,17 @@ scan: case ch == '#' && isWordStart: end = i break scan + case ch == '&' && !(i+1 < len(input) && strings.IndexByte("&>", input[i+1]) >= 0) && + !(i > 0 && strings.IndexByte("<>", input[i-1]) >= 0): + // A single & (not &&, &>, >& or <&) runs the command in the + // background + if err := add(i, "&"); err != nil { + return nil, err + } + items[len(items)-1].background = true + op = ";" + start = i + 1 + wordStart = true case ch == ';', ch == '&' && i+1 < len(input) && input[i+1] == '&', ch == '|' && i+1 < len(input) && input[i+1] == '|': @@ -1315,14 +1339,15 @@ func execInput(input string) error { continue } printError(lastErr) - lastErr = execCommand(item.command) + lastErr = execCommand(item.command, item.background) lastStatus = exitStatus(lastErr) } return lastErr } -// execCommand executes a pipeline of simple commands with redirections. -func execCommand(input string) error { +// execCommand executes a pipeline of simple commands with redirections, in +// the background if background is set. +func execCommand(input string, background bool) error { // Split the input into words and operators, expanding variables and ~ on // the way. tokens, err := tokenize(input) @@ -1340,7 +1365,11 @@ func execCommand(input string) error { if len(stages) == 0 { return nil } - return runPipeline(stages) + // "exit" with stopped jobs only warns, unless it is repeated right away + if name := stages[0].args; len(name) == 0 || (name[0] != "exit" && name[0] != "quit") { + exitWarned = false + } + return runPipeline(stages, strings.TrimSpace(input), background) } // redirect is a redirection of a simple command, e.g. 2>>file or 2>&1. @@ -1403,49 +1432,71 @@ func parsePipeline(tokens []token) ([]stage, error) { return append(stages, cur), nil } -// runPipeline runs the stages of a pipeline concurrently, connecting the -// stdout of each stage to the stdin of the next one. It returns the error of -// the last stage (so $? is its exit status, like in sh); errors of the other -// stages are printed. -func runPipeline(stages []stage) error { - waits := make([]func() error, len(stages)) +// runPipeline runs the stages of a pipeline concurrently as a job, +// connecting the stdout of each stage to the stdin of the next one. In the +// foreground it waits for the job and returns the error of the last stage (so +// $? is its exit status, like in sh); errors of the other stages are printed. +// In the background it adds the job to the job table and returns nil. +func runPipeline(stages []stage, text string, background bool) error { + j := &job{text: text} + if jobControl && !background { + shellTTY, _ = readline.GetState(ttyFd) + } + stdin := os.Stdin + if background && !jobControl { + // Without job control background jobs must not read from the + // terminal, like in sh + if devNull, err := os.Open(os.DevNull); err == nil { + stdin = devNull + } + } + var startErr error for i, st := range stages { stdout := os.Stdout var next *os.File if i < len(stages)-1 { r, w, err := os.Pipe() if err != nil { - // Let the stages that already run finish if stdin != os.Stdin { stdin.Close() } - for _, wait := range waits[:i] { - printError(wait()) - } - return err + startErr = err + break } stdout, next = w, r } - waits[i] = startStage(st, stdin, stdout, len(stages) > 1) + j.procs = append(j.procs, startStage(st, stdin, stdout, j, len(stages) > 1, background)) stdin = next } - - var err error - for i, wait := range waits { - err = wait() - if i < len(waits)-1 { - printError(err) + if startErr != nil { + // Let the stages that already run finish + if len(j.procs) > 0 { + waitForeground(j) } + return startErr } - return err + + if background { + addJob(j) + if interactiveShell { + pid := 0 + for _, p := range j.procs { + if p.cmd != nil { + pid = p.cmd.Process.Pid + } + } + fmt.Fprintf(os.Stderr, "[%d] %d\n", j.id, pid) + } + return nil + } + return waitForeground(j) } -// startStage starts a stage of a pipeline with the given stdin and stdout and -// returns a function that waits for it to finish. Pipe ends passed in are -// closed by startStage once the stage no longer needs them, so that the -// neighbors see EOF or get SIGPIPE. -func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error { +// startStage starts a stage of the pipeline of job j with the given stdin and +// stdout. Pipe ends passed in are closed by startStage once the stage no +// longer needs them, so that the neighbors see EOF or get SIGPIPE. +func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background bool) *proc { var owned []*os.File if stdin != os.Stdin { owned = append(owned, stdin) @@ -1458,9 +1509,6 @@ func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error f.Close() } } - result := func(err error) func() error { - return func() error { return err } - } // Connect the command to the terminal unless redirected. Without Stdin // the command would read from /dev/null, so interactive programs (su, @@ -1472,13 +1520,17 @@ func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error if err != nil || len(st.args) == 0 { // A command without arguments only creates or truncates the files closeOwned() - return result(err) + return finishedProc(err) } if builtin, ok := builtins[st.args[0]]; ok { - if inPipeline && builtin.changesState { + switch { + case inPipeline && builtin.changesState: closeOwned() - return result(fmt.Errorf("%s: cannot be used in a pipeline", st.args[0])) + return finishedProc(fmt.Errorf("%s: cannot be used in a pipeline", st.args[0])) + case background && builtin.changesState: + closeOwned() + return finishedProc(fmt.Errorf("%s: cannot be run in the background", st.args[0])) } run := func() error { defer closeOwned() @@ -1490,16 +1542,29 @@ func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error } return err } - if !inPipeline { - return result(run()) + if !inPipeline && !background { + return finishedProc(run()) } - done := make(chan error, 1) - go func() { done <- run() }() - return func() error { return <-done } + p := &proc{done: make(chan error, 1)} + go func() { p.done <- run() }() + return p } cmd := exec.Command(st.args[0], st.args[1:]...) cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2] + if jobControl { + // All processes of the job share a process group: the one of the + // first process. In the foreground, the first process makes its + // group the foreground group of the terminal before it starts the + // program, so that it can use the terminal right away. + attr := &syscall.SysProcAttr{Setpgid: true, Pgid: j.pgid} + if j.pgid == 0 && !background { + if ctty := ttyIndex(fds); ctty >= 0 { + attr.Foreground, attr.Ctty = true, ctty + } + } + cmd.SysProcAttr = attr + } err = cmd.Start() // The child has its own copies of the files now closeOwned() @@ -1507,9 +1572,16 @@ func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error err = fmt.Errorf("%s: %w", sanitize(st.args[0]), errCommandNotFound) } if err != nil { - return result(err) + return finishedProc(err) } - return cmd.Wait + if jobControl && j.pgid == 0 { + j.pgid = cmd.Process.Pid + if !background { + // In case the process could not do it (no terminal in fds) + setForeground(j.pgid) + } + } + return &proc{cmd: cmd} } // applyRedirects applies the redirections in order to fds (stdin, stdout, @@ -1573,6 +1645,10 @@ var builtins = map[string]struct{ changesState bool }{ "help": {}, "source": {changesState: true}, ".": {changesState: true}, + "jobs": {}, + "fg": {changesState: true}, + "bg": {changesState: true}, + "wait": {changesState: true}, } // runBuiltin executes a built-in command, writing its output to stdout. @@ -1617,17 +1693,17 @@ func runBuiltin(args []string, stdout io.Writer) error { if len(args) > 1 { var exitCode int fmt.Sscanf(args[1], "%d", &exitCode) - os.Exit(exitCode) + return exitShell(exitCode) } - os.Exit(lastStatus) + return exitShell(lastStatus) case "quit": // quit with optional status code if len(args) > 1 { var exitCode int fmt.Sscanf(args[1], "%d", &exitCode) - os.Exit(exitCode) + return exitShell(exitCode) } - os.Exit(lastStatus) + return exitShell(lastStatus) case "set": // set VAR=value - set environment variable if len(args) < 2 { @@ -1652,6 +1728,14 @@ func runBuiltin(args []string, stdout io.Writer) error { } os.Setenv(parts[0], parts[1]) return nil + case "jobs": + return builtinJobs(stdout) + case "fg": + return builtinFg(args, stdout) + case "bg": + return builtinBg(args, stdout) + case "wait": + return builtinWait(args) case "source", ".": // source file - run the commands of file in the current shell if len(args) < 2 { @@ -1670,6 +1754,10 @@ Built-in commands: set VAR=val - Set environment variable export VAR=val - Export environment variable source file - Run the commands of file in this shell (also: . file) + jobs - List background and stopped jobs + fg [%n] - Continue job n (default: current job) in the foreground + bg [%n] - Continue stopped job n in the background + wait [%n] - Wait for job n or all background jobs help - Show this help Features: @@ -1694,6 +1782,8 @@ Features: a || b (b only if a failed); $? is the exit status of the last command - Pipes (a | b) and redirections: < file, > file, >> file, 2> file, 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file + - Job control: command & runs in the background, Ctrl+Z stops the + foreground job; jobs, fg, bg and wait manage the jobs - Non-interactive use: gosh -c 'command', gosh script, command | gosh; the exit status is the one of the last command - Startup files: login shells (-l or argv[0] starting with -) read diff --git a/main_test.go b/main_test.go index a832100..0441931 100644 --- a/main_test.go +++ b/main_test.go @@ -226,7 +226,7 @@ func TestShell_EndToEnd(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - cmd := exec.Command("go", "run", "main.go") + cmd := exec.Command("go", "run", ".") cmd.Stdin = strings.NewReader(tc.input) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -259,7 +259,7 @@ func TestShell_EOF_ExitsGracefully(t *testing.T) { // Simulate EOF by sending empty input // Note: When using piped input, readline doesn't print prompts // So we just verify the shell exits cleanly - cmd := exec.Command("go", "run", "main.go") + cmd := exec.Command("go", "run", ".") cmd.Stdin = strings.NewReader("") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -278,7 +278,7 @@ func TestShell_EOF_ExitsGracefully(t *testing.T) { func TestShell_EOF_AfterCommand(t *testing.T) { // Test that EOF after a command works // Note: When using piped input, readline doesn't print prompts - cmd := exec.Command("go", "run", "main.go") + cmd := exec.Command("go", "run", ".") cmd.Stdin = strings.NewReader("echo test\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -302,7 +302,7 @@ func TestShell_SIGINT_HandlerSetup(t *testing.T) { // This test verifies that the shell doesn't panic when receiving signals // We can't easily test SIGINT handling from a test, but we can verify // the shell runs without crashing - cmd := exec.Command("go", "run", "main.go") + cmd := exec.Command("go", "run", ".") cmd.Stdin = strings.NewReader("echo test\nquit\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -458,7 +458,7 @@ func TestShell_ExitWithStatusCode(t *testing.T) { } // Build the binary once - build := exec.Command("go", "build", "-o", "gosh_test", "main.go") + build := exec.Command("go", "build", "-o", "gosh_test", ".") if err := build.Run(); err != nil { t.Fatalf("failed to build: %v", err) } @@ -494,7 +494,7 @@ func TestShell_ExitWithStatusCode(t *testing.T) { // ============================================================================= func TestShell_EnvExpansion_Home(t *testing.T) { - cmd := exec.Command("go", "run", "main.go") + cmd := exec.Command("go", "run", ".") cmd.Stdin = strings.NewReader("echo $HOME\nexit\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -513,7 +513,7 @@ func TestShell_EnvExpansion_Home(t *testing.T) { } func TestShell_EnvExpansion_Path(t *testing.T) { - cmd := exec.Command("go", "run", "main.go") + cmd := exec.Command("go", "run", ".") cmd.Stdin = strings.NewReader("echo $PATH\nexit\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -532,7 +532,7 @@ func TestShell_EnvExpansion_Path(t *testing.T) { } func TestShell_EnvExpansion_Braces(t *testing.T) { - cmd := exec.Command("go", "run", "main.go") + cmd := exec.Command("go", "run", ".") cmd.Stdin = strings.NewReader("echo ${HOME}\nexit\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -551,7 +551,7 @@ func TestShell_EnvExpansion_Braces(t *testing.T) { } func TestShell_EnvExpansion_CdWithVar(t *testing.T) { - cmd := exec.Command("go", "run", "main.go") + cmd := exec.Command("go", "run", ".") cmd.Stdin = strings.NewReader("cd $HOME\npwd\nexit\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -570,7 +570,7 @@ func TestShell_EnvExpansion_CdWithVar(t *testing.T) { } func TestShell_TildeExpansion_Ls(t *testing.T) { - cmd := exec.Command("go", "run", "main.go") + cmd := exec.Command("go", "run", ".") cmd.Stdin = strings.NewReader("ls ~\nexit\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -1369,16 +1369,23 @@ func TestSplitList(t *testing.T) { {"", nil}, {" ", nil}, {"# only a comment", nil}, - {"ls", []listItem{{"", "ls"}}}, - {"a; b", []listItem{{"", "a"}, {";", " b"}}}, - {"a;", []listItem{{"", "a"}}}, - {"a && b || c", []listItem{{"", "a "}, {"&&", " b "}, {"||", " c"}}}, - {`echo "a;b" 'c&&d' e\;f`, []listItem{{"", `echo "a;b" 'c&&d' e\;f`}}}, - {`echo "x\"; y"`, []listItem{{"", `echo "x\"; y"`}}}, - {"echo ${X:-a;b}; c", []listItem{{"", "echo ${X:-a;b}"}, {";", " c"}}}, - {"echo a # b; c", []listItem{{"", "echo a "}}}, - {"echo a#b; c", []listItem{{"", "echo a#b"}, {";", " c"}}}, - {"echo a|b", []listItem{{"", "echo a|b"}}}, + {"ls", []listItem{{"", "ls", false}}}, + {"a; b", []listItem{{"", "a", false}, {";", " b", false}}}, + {"a;", []listItem{{"", "a", false}}}, + {"a && b || c", []listItem{{"", "a ", false}, {"&&", " b ", false}, {"||", " c", false}}}, + {`echo "a;b" 'c&&d' e\;f`, []listItem{{"", `echo "a;b" 'c&&d' e\;f`, false}}}, + {`echo "x\"; y"`, []listItem{{"", `echo "x\"; y"`, false}}}, + {"echo ${X:-a;b}; c", []listItem{{"", "echo ${X:-a;b}", false}, {";", " c", false}}}, + {"echo a # b; c", []listItem{{"", "echo a ", false}}}, + {"echo a#b; c", []listItem{{"", "echo a#b", false}, {";", " c", false}}}, + {"echo a|b", []listItem{{"", "echo a|b", false}}}, + {"a & b", []listItem{{"", "a ", true}, {";", " b", false}}}, + {"a &", []listItem{{"", "a ", true}}}, + {"a&", []listItem{{"", "a", true}}}, + {"a 2>&1 &", []listItem{{"", "a 2>&1 ", true}}}, + {"a &> f &", []listItem{{"", "a &> f ", true}}}, + {"a >&2 && b", []listItem{{"", "a >&2 ", false}, {"&&", " b", false}}}, + {`echo "a & b" \&`, []listItem{{"", `echo "a & b" \&`, false}}}, } for _, tc := range tests { got, err := splitList(tc.input) @@ -1387,13 +1394,13 @@ func TestSplitList(t *testing.T) { continue } if !reflect.DeepEqual(got, tc.want) { - t.Errorf("splitList(%q) = %q, want %q", tc.input, got, tc.want) + t.Errorf("splitList(%q) = %+v, want %+v", tc.input, got, tc.want) } } } func TestSplitList_SyntaxErrors(t *testing.T) { - for _, input := range []string{"; a", "a ;; b", "&& a", "a &&", "a ||", "a && ; b", "a || && b"} { + for _, input := range []string{"; a", "a ;; b", "&& a", "a &&", "a ||", "a && ; b", "a || && b", "& a", "a & && b", "a & & b"} { if _, err := splitList(input); err == nil || !strings.Contains(err.Error(), "syntax error") { t.Errorf("splitList(%q) = %v, want syntax error", input, err) } @@ -1855,3 +1862,75 @@ func TestGetHomeDir_UsesHOME(t *testing.T) { t.Error("getHomeDir() is empty without $HOME") } } + +func TestExecInput_Background(t *testing.T) { + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + dir := t.TempDir() + os.Chdir(dir) + + // The shell does not wait for a background job, wait does + start := time.Now() + if err := execInput("sh -c 'sleep 0.3; echo done > out' &"); err != nil { + t.Fatal(err) + } + if lastStatus != 0 { + t.Errorf("$? = %d after starting a background job, want 0", lastStatus) + } + if time.Since(start) > 200*time.Millisecond { + t.Error("the shell waited for the background job") + } + if _, err := os.Stat("out"); err == nil { + t.Error("background job finished too early") + } + if err := execInput("wait"); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile("out"); string(got) != "done\n" { + t.Errorf("out = %q after wait", got) + } + if len(jobTable) != 0 { + t.Errorf("job table not empty after wait: %d jobs", len(jobTable)) + } + + // wait %n returns the status of the job + execInput("sh -c 'exit 3' &") + execInput("wait %1") + if lastStatus != 3 { + t.Errorf("$? = %d after wait %%1, want 3", lastStatus) + } + + // Without job control, background jobs read from /dev/null + execInput("cat > stdin.txt &") + execInput("wait") + if got, err := os.ReadFile("stdin.txt"); err != nil || len(got) != 0 { + t.Errorf("background job read %q, %v from stdin", got, err) + } + + // jobs lists running jobs, also into a redirection + execInput("sleep 0.3 &") + execInput("jobs > jobs.txt") + if got, _ := os.ReadFile("jobs.txt"); !strings.Contains(string(got), "Running sleep 0.3 &") { + t.Errorf("jobs output = %q", got) + } + execInput("wait") + + // Builtins that change the shell cannot run in the background + if err := execInput("cd / &"); err != nil { + t.Fatal(err) + } + execInput("wait %1") + if wd, _ := os.Getwd(); wd != dir { + t.Errorf("cd in the background changed the directory to %s", wd) + } + + // fg and bg need job control + for _, cmd := range []string{"fg", "bg"} { + if err := execInput(cmd); err == nil || err.Error() != cmd+": no job control" { + t.Errorf("%s without job control: %v", cmd, err) + } + } + if err := execInput("wait %9"); err == nil || err.Error() != "wait: %9: no such job" { + t.Errorf("wait %%9: %v", err) + } +}