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

+101 -22
View File
@@ -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)
}
}