From da281ca4ab43e2c73458f5095e3ab33d21e65101 Mon Sep 17 00:00:00 2001 From: Florian Walther Date: Sat, 3 Oct 2026 23:35:25 +0200 Subject: [PATCH] fix job control on FreeBSD and macOS: do not use WCONTINUED Go's syscall.WaitStatus decodes the "continued" status wrongly on BSD: on FreeBSD it is read as killed by SIGCONT, so after bg the job was reported as "Exit 147" and removed from the job table while it was still running (fg: "no such job"); on macOS it is read as stopped. The shell marks jobs as running itself when it continues them, so wait4 is called without WCONTINUED and only exited/signaled statuses finish a process. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + jobs.go | 27 +++++++++++++++++++-------- jobs_linux_test.go | 22 ++++++++++++++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d3d0b06..bbf065a 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,7 @@ $ bg %2 # oder: angehaltenen Job im Hintergrund weiterlauf - `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`. +- Wird ein angehaltener Job von außen fortgesetzt (`kill -CONT`), zeigt `jobs` ihn weiter als `Stopped`, bis er endet oder erneut angehalten wird; `fg` und `bg` funktionieren trotzdem. - 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/jobs.go b/jobs.go index 7d11ec9..d4e0e1c 100644 --- a/jobs.go +++ b/jobs.go @@ -162,9 +162,7 @@ func (j *job) update(pid int, ws syscall.WaitStatus) { switch { case ws.Stopped(): p.stopped = true - case ws.Continued(): - p.stopped = false - default: + case ws.Exited() || ws.Signaled(): p.finished, p.stopped = true, false p.err = waitStatusError(ws) p.cmd.Process.Release() @@ -185,17 +183,30 @@ func waitStatusError(ws syscall.WaitStatus) error { 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) { +// waitFlags returns the options for wait4: WUNTRACED to see stopped +// processes (with job control) and WNOHANG to not block. +// +// WCONTINUED is not used: Go's syscall.WaitStatus decodes the "continued" +// status wrongly on FreeBSD (as killed by SIGCONT) and macOS (as stopped). +// The shell marks jobs as running itself when it continues them (fg, bg); a +// job continued from outside (kill -CONT) stays "Stopped" in the job table +// until it stops again or exits. +func waitFlags(j *job, nohang bool) int { flags := 0 if jobControl && j.pgid != 0 { - flags |= syscall.WUNTRACED | syscall.WCONTINUED + flags |= syscall.WUNTRACED } if nohang { flags |= syscall.WNOHANG } + return flags +} + +// 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 := waitFlags(j, nohang) for { // With job control all processes of the job are in its process diff --git a/jobs_linux_test.go b/jobs_linux_test.go index 01e6b22..d79dae3 100644 --- a/jobs_linux_test.go +++ b/jobs_linux_test.go @@ -105,6 +105,21 @@ func (s *ptyShell) expect(want string) { s.t.Fatalf("timeout waiting for %q, output:\n%s", want, s.out.String()) } +func TestWaitFlags_NoWCONTINUED(t *testing.T) { + defer func(saved bool) { jobControl = saved }(jobControl) + jobControl = true + j := &job{pgid: 1} + for _, nohang := range []bool{false, true} { + flags := waitFlags(j, nohang) + if flags&syscall.WCONTINUED != 0 { + t.Errorf("waitFlags(nohang=%v) contains WCONTINUED, which Go decodes wrongly on BSD", nohang) + } + if flags&syscall.WUNTRACED == 0 { + t.Errorf("waitFlags(nohang=%v) misses WUNTRACED", nohang) + } + } +} + func TestJobControl(t *testing.T) { s := startPtyShell(t) @@ -120,6 +135,13 @@ func TestJobControl(t *testing.T) { s.send("bg\n") s.expect("[1]+ Running sleep 30 &") + // After bg the job keeps running and is not reported as finished + // (regression: on FreeBSD the "continued" status was read as killed by + // SIGCONT, "Exit 147") + time.Sleep(300 * time.Millisecond) + s.send("jobs\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")