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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user