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
+101
-22
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user