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:
Florian WaltherandClaude Opus 5.5 committed 2026-10-03 23:22:21 +02:00
1 parent 4b44cc6e72
commit dae4087a94
6 files changed
+990 -73

No files matched your search

+139 -49
View File
@@ -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